diff --git a/.github/workflows/repository-dispatch.yml b/.github/workflows/repository-dispatch.yml index b1a1fa7b668..820d75d47eb 100644 --- a/.github/workflows/repository-dispatch.yml +++ b/.github/workflows/repository-dispatch.yml @@ -36,6 +36,7 @@ jobs: # Either a literal path, or the name of a secret... repo: - "opsmill/infrahub-demo-dc" + - "opsmill/infrahub-demo-sp" - "INFRAHUB_PACKER_REPOSITORY" - "INFRAHUB_ENTERPRISE_REPOSITORY" - "INFRAHUB_CUSTOMER1_REPOSITORY" diff --git a/.vale/styles/spelling-exceptions.txt b/.vale/styles/spelling-exceptions.txt index 386856f9532..906f58a0273 100644 --- a/.vale/styles/spelling-exceptions.txt +++ b/.vale/styles/spelling-exceptions.txt @@ -161,6 +161,7 @@ namespaces nats Nautobot Neo4j +netmask NGINX Netbox Netutils diff --git a/AGENTS.md b/AGENTS.md index 9787b185e6e..578a9683647 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,12 +19,12 @@ Style: be direct and substantive. No filler, preamble, or pleasantries. Challeng - `backend/` – Python backend (FastAPI, GraphQL, core logic) - see [backend/AGENTS.md](backend/AGENTS.md) - `frontend/app/` – React frontend - see [frontend/app/AGENTS.md](frontend/app/AGENTS.md) -- `docs/` – Docusaurus documentation - see [docs/AGENTS.md](docs/AGENTS.md) +- `docs/` – External customers documentation by Docusaurus, deployed at https://docs.infrahub.app/ - see [docs/AGENTS.md](docs/AGENTS.md) +- `dev/` – Internal developer documentation - see [dev/README.md](dev/README.md) - `python_sdk/` – Python SDK (Git submodule) - `tasks/` – Invoke task definitions - `schema/` – JSON/GraphQL schema definitions - `changelog/` – Towncrier changelog fragments -- `dev/` – Internal developer documentation - see [dev/README.md](dev/README.md) ## Commands diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 949a5dbda30..dd41a5e9571 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -109,6 +109,7 @@ Each entry says *when* to load it — open the doc before working in that area. - `dev/knowledge/backend/events.md` - Events system; read when adding or changing an event - `dev/knowledge/backend/async-tasks.md` - Prefect workflows, priority lanes, failure/best-effort handling; read before creating or changing a workflow - `dev/knowledge/backend/message-bus.md` - Message bus system; read when adding or changing a message +- `dev/knowledge/backend/telemetry.md` - Anonymous usage telemetry (categories, windowing, retention, degradation); read when adding or changing telemetry metrics or the collection window - `dev/knowledge/backend/webhooks.md` - Webhook delivery and failure classification; read when touching webhook delivery - `dev/knowledge/backend/computed-attributes.md` - Jinja2 computed attributes and their recompute paths; read when touching Jinja2 computed attributes - `dev/knowledge/backend/display-labels-and-hfid.md` - Display-label and human-friendly-id derivation; read when touching either @@ -118,7 +119,7 @@ Each entry says *when* to load it — open the doc before working in that area. ### Guides (How to do X) - `dev/guides/backend/creating-events.md` - Creating new events -- `dev/guides/backend/creating-async-tasks.md` - Creating async tasks +- `dev/guides/backend/creating-async-tasks.md` - How to create an async task, with a pre-submit checklist. Load when adding a `@task`/`@flow`. - `dev/guides/backend/creating-messages.md` - Creating message bus messages ### ADRs (Why we decided) diff --git a/backend/infrahub/api/schema.py b/backend/infrahub/api/schema.py index acd8d18a372..6089918bb7d 100644 --- a/backend/infrahub/api/schema.py +++ b/backend/infrahub/api/schema.py @@ -11,11 +11,13 @@ TemplateSchemaRead, ) from infrahub_sdk.schema.generated.write import InfrahubSchemaWrite +from infrahub_sdk.schema.validate import SchemaValidationWarningDetail from infrahub_sdk.schema.validate import validate_schema as validate_write_schema from pydantic import ( BaseModel, Field, PrivateAttr, + ValidatorFunctionWrapHandler, computed_field, create_model, model_validator, @@ -45,6 +47,8 @@ ProfileSchema, SchemaRoot, SchemaWarning, + SchemaWarningKind, + SchemaWarningType, TemplateSchema, ) from infrahub.core.schema.constants import SchemaNamespace # noqa: TC001 @@ -99,18 +103,51 @@ class SchemaReadAPI(BaseModel): namespaces: list[SchemaNamespace] = Field(default_factory=list) +def read_only_field_warnings(details: list[SchemaValidationWarningDetail]) -> list[SchemaWarning]: + """Aggregate read-only field findings into one warning per field name. + + A payload read back from the schema API repeats the same read-only field on every node and + attribute it contains, so grouping by field name keeps the response proportional to the number + of distinct offending fields rather than to the size of the schema. + """ + grouped: dict[str, list[SchemaWarningKind]] = {} + for detail in details: + kinds = grouped.setdefault(detail.name, []) + if detail.kind is None: + continue + kind = SchemaWarningKind(kind=detail.kind, field=detail.element) + if kind not in kinds: + kinds.append(kind) + + return [ + SchemaWarning( + type=SchemaWarningType.DEPRECATION, + kinds=kinds, + message=f"'{name}' is a read-only field, the submitted value is ignored", + ) + for name, kinds in grouped.items() + ] + + class SchemaLoadAPI(InfrahubSchemaWrite): _internal_schema: SchemaRoot = PrivateAttr() + _contract_warnings: list[SchemaWarning] = PrivateAttr(default_factory=list) - @model_validator(mode="before") + @model_validator(mode="wrap") @classmethod - def validate_write_contract(cls, data: Any) -> Any: + def validate_write_contract(cls, data: Any, handler: ValidatorFunctionWrapHandler) -> Self: + # Wrapped rather than run before validation so the warnings, which are only visible on the + # raw payload, can be carried on the instance the handler returns. + result = validate_write_schema(schema=data) if isinstance(data, dict) else None # Raising here turns the field-level messages into a single request-validation error. - if isinstance(data, dict): - result = validate_write_schema(schema=data) - if not result.valid: - raise ValueError("; ".join(result.messages)) - return data + if result is not None and not result.valid: + raise ValueError("; ".join(result.messages)) + + instance: Self = handler(data) + + if result is not None: + instance._contract_warnings = read_only_field_warnings(details=result.warnings) + return instance @model_validator(mode="after") def build_internal_schema(self) -> Self: @@ -122,6 +159,10 @@ def build_internal_schema(self) -> Self: def internal_schema(self) -> SchemaRoot: return self._internal_schema + @property + def contract_warnings(self) -> list[SchemaWarning]: + return self._contract_warnings + class SchemasLoadAPI(BaseModel): schemas: list[SchemaLoadAPI] @@ -367,6 +408,7 @@ async def load_schema( candidate_schemas.append(internal_schema) errors += internal_schema.validate_reserved_names() warnings += internal_schema.gather_warnings() + warnings += schema.contract_warnings if errors: raise SchemaNotValidError(message=", ".join(errors)) @@ -380,7 +422,7 @@ async def load_schema( ) if not result.diff.all: - return SchemaUpdate(hash=original_hash, previous_hash=original_hash, diff=result.diff) + return SchemaUpdate(hash=original_hash, previous_hash=original_hash, diff=result.diff, warnings=warnings) # ---------------------------------------------------------- # Validate if the new schema is valid with the content of the database @@ -460,6 +502,7 @@ async def check_schema( candidate_schemas.append(internal_schema) errors += internal_schema.validate_reserved_names() warnings += internal_schema.gather_warnings() + warnings += schema.contract_warnings if errors: raise SchemaNotValidError(message=", ".join(errors)) diff --git a/backend/infrahub/branch/status_checker.py b/backend/infrahub/branch/status_checker.py index de1f7b34486..6f36947b834 100644 --- a/backend/infrahub/branch/status_checker.py +++ b/backend/infrahub/branch/status_checker.py @@ -24,7 +24,7 @@ MERGE_RECOVERY_REQUIRED_MESSAGE = ( "A previous merge failed and left the default branch protected. Writes stay blocked until an " - "administrator runs `infrahub recover`. Please contact an administrator." + "administrator runs `infrahub recover merge`. Please contact an administrator." ) @@ -58,7 +58,7 @@ async def check_merging_status(self, branch: Branch) -> None: - MERGING: transient — the default branch becomes writable again once the merge completes, so the target gate raises the retryable MergeInProgressError, and - MERGE_FAILED: durable — a previous merge died, so the gate raises MergeRecoveryRequiredError - (a distinct, non-retryable code) until an administrator runs ``infrahub recover``. + (a distinct, non-retryable code) until an administrator runs ``infrahub recover merge``. If the cache lookup fails — unreachable backend, or a present-but-corrupt value that cannot be interpreted as "no merge in progress" — the gate falls back to the durable branch status in the diff --git a/backend/infrahub/computed_attribute/jinja2.py b/backend/infrahub/computed_attribute/jinja2.py index bf90884f4b0..ff6fbd15946 100644 --- a/backend/infrahub/computed_attribute/jinja2.py +++ b/backend/infrahub/computed_attribute/jinja2.py @@ -84,3 +84,11 @@ def __init__( filters={**FILTERS, **(filters or {})}, client=client, ) + + def get_referenced_root_fields(self) -> set[str]: + """Root schema field names the template depends on (the segment before ``__``). + + A variable such as ``owner__name__value`` resolves to the root field ``owner``; ``__`` is + the schema-path separator, so a field name never contains it. + """ + return {variable.split("__")[0] for variable in self.get_variables()} diff --git a/backend/infrahub/core/attribute.py b/backend/infrahub/core/attribute.py index fbce82e7e7e..ae2fbba17cd 100644 --- a/backend/infrahub/core/attribute.py +++ b/backend/infrahub/core/attribute.py @@ -373,8 +373,13 @@ def deserialize_value(self, data: AttributeFromDB) -> Any: """Deserialize the value coming from the database.""" return data.value - def _normalize_value(self, value: Any) -> Any: - """Return the canonical form of a value.""" + @classmethod + def _normalize_value(cls, value: Any) -> Any: + """Return the canonical form of a value. + + Exposed on the class so that a value can be checked for canonicality without building an + attribute instance. + """ return value async def save( @@ -1007,7 +1012,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None except ValueError as exc: raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc - def _normalize_value(self, value: Any) -> str: + @classmethod + def _normalize_value(cls, value: Any) -> str: return ipaddress.ip_network(value).with_prefixlen def get_db_node_type(self) -> AttributeDBNodeType: @@ -1147,7 +1153,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None except ValueError as exc: raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc - def _normalize_value(self, value: Any) -> str: + @classmethod + def _normalize_value(cls, value: Any) -> str: return ipaddress.ip_interface(value).with_prefixlen def get_db_node_type(self) -> AttributeDBNodeType: @@ -1170,6 +1177,91 @@ class IPHostOptional(IPHost): value: str | None +class IPAddress(BaseAttribute): + type = str + value: str + + @staticmethod + def get_allowed_property_in_path() -> list[str]: + return ["binary_address", "value", "version"] + + @property + def obj(self) -> ipaddress.IPv4Address | ipaddress.IPv6Address: + """Return the bare ip address without a prefix or subnet mask. + + Raises: + ValueError: When the IP address value has not been defined. + + """ + if not self.value: + raise ValueError("value for IPAddress must be defined") + return ipaddress.ip_address(str(self.value)) + + @property + def version(self) -> int | None: + """Return the IP version of the ip address.""" + if not self.value: + return None + return self.obj.version + + @property + def ip_integer(self) -> int: + """Return the ip address as an integer.""" + return int(self.obj) + + @property + def ip_binary(self) -> str: + """Return the ip address in binary format.""" + return convert_ip_to_binary_str(obj=self.obj) + + @classmethod + def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None: + """Validate the format of the attribute. + + A bare address is required, so any prefix length or netmask suffix is rejected. + + Args: + value (Any): value to validate + name (str): name of the attribute to include in a potential error message + schema (AttributeSchema): schema for this attribute + + Raises: + ValidationError: Format of the attribute value is not valid + + """ + super().validate_format(value=value, name=name, schema=schema) + + try: + ipaddress.ip_address(value) + except ValueError as exc: + raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc + + @classmethod + def _normalize_value(cls, value: Any) -> str: + return str(ipaddress.ip_address(value)) + + def get_db_node_type(self) -> AttributeDBNodeType: + if self.value is not None: + return AttributeDBNodeType.IPHOST + return super().get_db_node_type() + + def to_db(self) -> dict[str, Any]: + data = super().to_db() + + if self.value is not None: + data["version"] = self.version + data["binary_address"] = self.ip_binary + # The shared AttributeIPHost vertex requires prefixlen, and neo4j refuses to MERGE on a + # null property. A bare address is a single host, so its prefix length is the maximum. + data["prefixlen"] = self.obj.max_prefixlen + + return data + + +class IPAddressOptional(IPAddress): + value: str | None + + class MacAddress(BaseAttribute): type = str value: str @@ -1273,7 +1365,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None if not netaddr.valid_mac(addr=str(value)): raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) - def _normalize_value(self, value: Any) -> str: + @classmethod + def _normalize_value(cls, value: Any) -> str: return netaddr.EUI(addr=value).format(dialect=netaddr.mac_unix_expanded).upper() def serialize_value(self) -> str: diff --git a/backend/infrahub/core/branch/models.py b/backend/infrahub/core/branch/models.py index 099e2e8da64..b7103b742a4 100644 --- a/backend/infrahub/core/branch/models.py +++ b/backend/infrahub/core/branch/models.py @@ -208,6 +208,9 @@ async def get_list_count( partial_match: bool = False, branch_filters: BranchListFilters | None = None, node_ordering: StandardNodeOrdering | None = None, + exclude_global: bool = False, + exclude_default: bool = False, + exclude_terminal: bool = False, **_kwargs: Any, ) -> int: if branch_filters is None: @@ -223,7 +226,9 @@ async def get_list_count( node_class=cls, branch_filters=branch_filters, limit=limit, - exclude_global=True, + exclude_global=exclude_global, + exclude_default=exclude_default, + exclude_terminal=exclude_terminal, node_ordering=node_ordering, ) return await query.count(db=db) diff --git a/backend/infrahub/core/branch/tasks.py b/backend/infrahub/core/branch/tasks.py index ea585caddcb..9c480375164 100644 --- a/backend/infrahub/core/branch/tasks.py +++ b/backend/infrahub/core/branch/tasks.py @@ -192,10 +192,10 @@ async def rebase_branch(branch: str, context: InfrahubContext, send_events: bool ) candidate_schema = schema_analyzer.get_candidate_schema() - determiner = build_constraint_validator_determiner( - db=db, branch=user_branch, schema_branch=candidate_schema, at=rebase_at + determiner = build_constraint_validator_determiner(db=db, branch=user_branch, at=rebase_at) + data_diff_constraints = await determiner.get_constraints( + schema_branch=candidate_schema, node_diffs=node_diff_field_summaries ) - data_diff_constraints = await determiner.get_constraints(node_diffs=node_diff_field_summaries) # If there are some changes related to the schema between this branch and main, we need to # - Run all the validations to ensure everything is correct before rebasing the branch @@ -203,8 +203,8 @@ async def rebase_branch(branch: str, context: InfrahubContext, send_events: bool schema_diff_constraints: list[SchemaUpdateConstraintInfo] = [] if user_branch.has_schema_changes: schema_diff_constraints = await schema_analyzer.calculate_validations(target_schema=candidate_schema) - merger = build_constraint_info_merger(schema_branch=candidate_schema) - constraints = merger.merge(data_diff_constraints, schema_diff_constraints) + merger = build_constraint_info_merger() + constraints = merger.merge(candidate_schema, data_diff_constraints, schema_diff_constraints) if constraints: responses = await schema_validate_migrations( message=SchemaValidateMigrationData( diff --git a/backend/infrahub/core/diff/model/path.py b/backend/infrahub/core/diff/model/path.py index 6ac24b0a446..0e083ed423f 100644 --- a/backend/infrahub/core/diff/model/path.py +++ b/backend/infrahub/core/diff/model/path.py @@ -121,6 +121,20 @@ def add_relationship_node_uuid(self, name: str, node_uuid: str) -> None: """Record that `node_uuid` is a node of this kind whose relationship `name` changed.""" self.relationship_node_uuids.setdefault(name, set()).add(node_uuid) + def merge(self, other: NodeDiffFieldSummary) -> None: + """Fold another summary of the same kind into this one. + + Raises: + ValueError: If the other summary is for a different kind. + + """ + if other.kind != self.kind: + raise ValueError(f"Cannot merge summary for kind {other.kind} into summary for kind {self.kind}") + for name, node_uuids in other.attribute_node_uuids.items(): + self.attribute_node_uuids.setdefault(name, set()).update(node_uuids) + for name, node_uuids in other.relationship_node_uuids.items(): + self.relationship_node_uuids.setdefault(name, set()).update(node_uuids) + @property def attribute_names(self) -> set[str]: return set(self.attribute_node_uuids) diff --git a/backend/infrahub/core/diff/query/field_summary.py b/backend/infrahub/core/diff/query/field_summary.py index 645f6b044c7..e350f77478b 100644 --- a/backend/infrahub/core/diff/query/field_summary.py +++ b/backend/infrahub/core/diff/query/field_summary.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from collections.abc import Generator from typing import Any from infrahub.core.constants import DiffAction @@ -8,29 +8,29 @@ from ..model.path import NodeDiffFieldSummary, TrackingId -@dataclass -class FieldNodeUuidsRow: - """One changed field of a kind and the uuids of the nodes that changed it, as projected by the query.""" - - name: str - node_uuids: list[str] - - class EnrichedDiffNodeFieldSummaryQuery(Query): - """Get node kind and names of all altered attributes and relationships for each kind.""" + """Get the names of all altered attributes and relationships for one page of changed nodes. + + Pagination is over the changed nodes, strictly ordered, with each row carrying every altered + field name of one node; aggregating the fields of every node in one transaction does not scale + with large diffs. + """ name = "enriched_diff_node_field_summary" type = QueryType.READ + insert_limit = False def __init__( self, diff_branch_name: str, + limit: int, tracking_id: TrackingId | None = None, diff_id: str | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) self.diff_branch_name = diff_branch_name + self.limit = limit self.tracking_id = tracking_id self.diff_id = diff_id @@ -42,6 +42,8 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa "diff_branch": self.diff_branch_name, "tracking_id": self.tracking_id.serialize() if self.tracking_id else None, "diff_id": self.diff_id, + "node_offset": self.offset or 0, + "node_limit": self.limit, } query = """ MATCH (diff_root:DiffRoot) @@ -51,48 +53,40 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa AND (diff_root.uuid = $diff_id OR $diff_id IS NULL) OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode) WHERE n.action <> $unchanged_str - WITH DISTINCT diff_root, n.kind AS kind - CALL (diff_root, kind) { - OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode {kind: kind})-[:DIFF_HAS_ATTRIBUTE]->(a:DiffAttribute) - WHERE n.action <> $unchanged_str - AND a.action <> $unchanged_str - WITH a.name AS attr_name, collect(DISTINCT n.uuid) AS attr_node_uuids - WHERE attr_name IS NOT NULL - RETURN collect({name: attr_name, node_uuids: attr_node_uuids}) AS attr_name_uuids + WITH n + ORDER BY n.uuid, elementId(n) + SKIP $node_offset + LIMIT $node_limit + CALL (n) { + OPTIONAL MATCH (n)-[:DIFF_HAS_ATTRIBUTE]->(a:DiffAttribute) + WHERE a.action <> $unchanged_str + RETURN collect(DISTINCT a.name) AS attr_names } - WITH diff_root, kind, attr_name_uuids - CALL (diff_root, kind) { - OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode {kind: kind})-[:DIFF_HAS_RELATIONSHIP]->(r:DiffRelationship) - WHERE n.action <> $unchanged_str - AND r.action <> $unchanged_str - WITH r.name AS rel_name, collect(DISTINCT n.uuid) AS rel_node_uuids - WHERE rel_name IS NOT NULL - RETURN collect({name: rel_name, node_uuids: rel_node_uuids}) AS rel_name_uuids + CALL (n) { + OPTIONAL MATCH (n)-[:DIFF_HAS_RELATIONSHIP]->(r:DiffRelationship) + WHERE r.action <> $unchanged_str + RETURN collect(DISTINCT r.name) AS rel_names } """ self.add_to_query(query=query) - self.order_by = ["kind"] - self.return_labels = ["kind", "attr_name_uuids", "rel_name_uuids"] + self.return_labels = ["n.kind AS kind", "n.uuid AS node_uuid", "attr_names", "rel_names"] - async def get_field_summaries(self) -> list[NodeDiffFieldSummary]: - field_summaries = [] - for result in self.get_results(): - kind = result.get_as_type(label="kind", return_type=str) - attribute_node_uuids = self._to_field_uuids( - result.get_as_list_of_type(label="attr_name_uuids", return_type=FieldNodeUuidsRow) - ) - relationship_node_uuids = self._to_field_uuids( - result.get_as_list_of_type(label="rel_name_uuids", return_type=FieldNodeUuidsRow) - ) - if attribute_node_uuids or relationship_node_uuids: - field_summaries.append( - NodeDiffFieldSummary( - kind=kind, - attribute_node_uuids=attribute_node_uuids, - relationship_node_uuids=relationship_node_uuids, - ) - ) - return field_summaries + def get_node_field_rows(self) -> Generator[NodeDiffFieldSummary, None, None]: + """Yield a single-node field summary for each changed node in this page. - def _to_field_uuids(self, rows: list[FieldNodeUuidsRow]) -> dict[str, set[str]]: - return {row.name: set(row.node_uuids) for row in rows} + A node whose fields are all unchanged still yields a summary (with empty field maps) so the + caller can count the nodes consumed from this page. A diff root with no changed nodes at all + produces one node-less row instead; it is skipped here, which is safe for the caller's + consumed-node count because null nodes sort after every real node. + """ + for result in self.get_results(): + kind = result.get_as_str("kind") + node_uuid = result.get_as_str("node_uuid") + if not kind or not node_uuid: + continue + summary = NodeDiffFieldSummary(kind=kind) + for attr_name in result.get_as_list_of_type(label="attr_names", return_type=str): + summary.add_attribute_node_uuid(name=attr_name, node_uuid=node_uuid) + for rel_name in result.get_as_list_of_type(label="rel_names", return_type=str): + summary.add_relationship_node_uuid(name=rel_name, node_uuid=node_uuid) + yield summary diff --git a/backend/infrahub/core/diff/repository/repository.py b/backend/infrahub/core/diff/repository/repository.py index 2af17832ec9..fb292bfdf74 100644 --- a/backend/infrahub/core/diff/repository/repository.py +++ b/backend/infrahub/core/diff/repository/repository.py @@ -588,11 +588,32 @@ async def get_affected_node_uuids( async def get_node_field_summaries( self, diff_branch_name: str, tracking_id: TrackingId | None = None, diff_id: str | None = None ) -> list[NodeDiffFieldSummary]: - query = await EnrichedDiffNodeFieldSummaryQuery.init( - db=self.db, diff_branch_name=diff_branch_name, tracking_id=tracking_id, diff_id=diff_id - ) - await query.execute(db=self.db) - return await query.get_field_summaries() + node_limit = config.SETTINGS.database.query_size_limit + node_offset = 0 + summaries_by_kind: dict[str, NodeDiffFieldSummary] = {} + while True: + query = await EnrichedDiffNodeFieldSummaryQuery.init( + db=self.db, + diff_branch_name=diff_branch_name, + tracking_id=tracking_id, + diff_id=diff_id, + offset=node_offset, + limit=node_limit, + ) + await query.execute(db=self.db) + num_nodes = 0 + for node_summary in query.get_node_field_rows(): + num_nodes += 1 + if not node_summary.attribute_node_uuids and not node_summary.relationship_node_uuids: + continue + kind_summary = summaries_by_kind.setdefault( + node_summary.kind, NodeDiffFieldSummary(kind=node_summary.kind) + ) + kind_summary.merge(node_summary) + if num_nodes < node_limit: + break + node_offset += node_limit + return list(summaries_by_kind.values()) async def mark_tracking_ids_merged(self, tracking_ids: list[TrackingId]) -> None: query = await EnrichedDiffMergedTrackingIdQuery.init(db=self.db, tracking_ids=tracking_ids) diff --git a/backend/infrahub/core/manager.py b/backend/infrahub/core/manager.py index faf007eed15..8787fa636b8 100644 --- a/backend/infrahub/core/manager.py +++ b/backend/infrahub/core/manager.py @@ -1328,7 +1328,9 @@ async def _enrich_one_node_with_relationships( direction=rel_schema.direction, peer_id=peer_id, ) - peer_with_metadata = PeerWithRelationshipMetadata(peer=peer) + peer_with_metadata = PeerWithRelationshipMetadata( + peer=peer, peer_kind=grouped_peer_nodes.get_peer_kind(peer_id=peer_id) + ) if not metadata_map: rel_peers_with_metadata.append(peer_with_metadata) continue diff --git a/backend/infrahub/core/merge/builder.py b/backend/infrahub/core/merge/builder.py index e7b228a8b02..84054c5ee4d 100644 --- a/backend/infrahub/core/merge/builder.py +++ b/backend/infrahub/core/merge/builder.py @@ -12,6 +12,8 @@ from infrahub.core.registry import registry from infrahub.core.rollback import GraphRollbacker from infrahub.core.schema.update_coordinator import SchemaUpdateCoordinator +from infrahub.core.validators.constraint_merge import build_constraint_info_merger +from infrahub.core.validators.determiner import build_constraint_validator_determiner from infrahub.core.validators.tasks import schema_validate_migrations from infrahub.dependencies.registry import get_component_registry from infrahub.workers.dependencies import get_cache, get_event_service, get_workflow @@ -65,9 +67,10 @@ async def build_branch_merge_orchestrator( schema_manager=registry.schema, ) constraint_validator = MergeConstraintValidator( - db=db, branch=source_branch, diff_repository=diff_repository, + determiner=build_constraint_validator_determiner(db=db, branch=source_branch), + constraint_info_merger=build_constraint_info_merger(), migration_validator=schema_validate_migrations, ) graph_merger = GraphMerger( diff --git a/backend/infrahub/core/merge/constraints.py b/backend/infrahub/core/merge/constraints.py index 36672344daf..2d19f83d93f 100644 --- a/backend/infrahub/core/merge/constraints.py +++ b/backend/infrahub/core/merge/constraints.py @@ -5,8 +5,6 @@ from infrahub.core.diff.model.diff import SchemaConflict from infrahub.core.diff.model.path import BranchTrackingId -from infrahub.core.validators.constraint_merge import build_constraint_info_merger -from infrahub.core.validators.determiner import build_constraint_validator_determiner from infrahub.core.validators.models.validate_migration import SchemaValidateMigrationData if TYPE_CHECKING: @@ -16,9 +14,10 @@ from infrahub.core.diff.repository.repository import DiffRepository from infrahub.core.models import SchemaUpdateConstraintInfo from infrahub.core.schema.schema_branch import SchemaBranch + from infrahub.core.validators.constraint_merge import ConstraintInfoMerger + from infrahub.core.validators.determiner import ConstraintValidatorDeterminer from infrahub.core.validators.model import SchemaViolation from infrahub.core.validators.models.validate_migration import SchemaValidatorPathResponseData - from infrahub.database import InfrahubDatabase MigrationValidator = Callable[[SchemaValidateMigrationData], Awaitable[list[SchemaValidatorPathResponseData]]] @@ -101,28 +100,30 @@ class MergeConstraintValidator: def __init__( self, - db: InfrahubDatabase, branch: Branch, diff_repository: DiffRepository, + determiner: ConstraintValidatorDeterminer, + constraint_info_merger: ConstraintInfoMerger, migration_validator: MigrationValidator, ) -> None: - self.db = db self.branch = branch self.diff_repository = diff_repository + self.determiner = determiner + self.constraint_info_merger = constraint_info_merger self.migration_validator = migration_validator async def validate( self, candidate_schema: SchemaBranch, schema_diff_constraints: list[SchemaUpdateConstraintInfo] ) -> MergeConstraintValidationResult: - determiner = build_constraint_validator_determiner( - db=self.db, branch=self.branch, schema_branch=candidate_schema - ) node_field_summaries = await self.diff_repository.get_node_field_summaries( diff_branch_name=self.branch.name, tracking_id=BranchTrackingId(name=self.branch.name) ) - data_diff_constraints = await determiner.get_constraints(node_diffs=node_field_summaries) - merger = build_constraint_info_merger(schema_branch=candidate_schema) - constraints = merger.merge(data_diff_constraints, schema_diff_constraints) + data_diff_constraints = await self.determiner.get_constraints( + schema_branch=candidate_schema, node_diffs=node_field_summaries + ) + constraints = self.constraint_info_merger.merge( + candidate_schema, data_diff_constraints, schema_diff_constraints + ) if not constraints: return MergeConstraintValidationResult() diff --git a/backend/infrahub/core/merge/recompute_coalescing.py b/backend/infrahub/core/merge/recompute_coalescing.py index c00bbba574e..949e099b39b 100644 --- a/backend/infrahub/core/merge/recompute_coalescing.py +++ b/backend/infrahub/core/merge/recompute_coalescing.py @@ -221,24 +221,70 @@ def build(self, *, changes: Iterable[MergeChange], branch: str) -> CoalescedReco def _resolve_targets(self, *, signature: ChangeSignature) -> Iterator[_ResolvedTarget]: if signature.action == CREATED: - include_self, include_cross = True, False - fields: frozenset[str] | None = None - precise = True - elif signature.action == UPDATED: - include_self, include_cross = False, True - # An update with no recorded fields cannot be scoped, so fall back to every - # field rather than risk missing a reader (over-recompute is safe). - fields = signature.changed_fields or None - precise = bool(signature.changed_fields) - elif signature.action == DELETED: - include_self, include_cross = False, True - fields = None - precise = True - else: - raise ValueError(f"Unknown change action: {signature.action!r}") + yield from self._derive_family_targets( + kind=signature.kind, fields=None, include_self=True, include_cross=False, precise=True + ) + return + if signature.action == DELETED: + yield from self._derive_family_targets( + kind=signature.kind, fields=None, include_self=False, include_cross=True, precise=True + ) + return + if signature.action == UPDATED: + if not signature.changed_fields: + # No fields to scope on: recompute self and cross, since the unknown change may be a + # relationship the node reads and under-recompute is not acceptable. + yield from self._derive_family_targets( + kind=signature.kind, fields=None, include_self=True, include_cross=True, precise=False + ) + return + # The node refreshed its own values inline on the save; only cross-node readers remain. + yield from self._derive_family_targets( + kind=signature.kind, + fields=signature.changed_fields, + include_self=False, + include_cross=True, + precise=True, + ) + # A relationship change that doesn't save the reader (e.g. a peer deleted on another branch) + # skips the reader's inline recompute, so refresh its own values here. + relationship_fields = self._changed_relationship_fields( + kind=signature.kind, changed_fields=signature.changed_fields + ) + if relationship_fields: + yield from self._derive_family_targets( + kind=signature.kind, + fields=relationship_fields, + include_self=True, + include_cross=False, + precise=True, + ) + return + raise ValueError(f"Unknown change action: {signature.action!r}") + + def _changed_relationship_fields(self, *, kind: str, changed_fields: frozenset[str]) -> frozenset[str] | None: + """Return the changed fields that name a relationship on ``kind`` (None if none). + + Any node-like kind (profiles and templates included) carries relationships, and a kind absent + from the branch yields None instead of raising. + """ + if not self.schema_branch.has(name=kind): + return None + node_schema = self.schema_branch.get(name=kind, duplicate=False) + matched = changed_fields & {relationship.name for relationship in node_schema.relationships} + return frozenset(matched) or None + def _derive_family_targets( + self, + *, + kind: str, + fields: frozenset[str] | None, + include_self: bool, + include_cross: bool, + precise: bool, + ) -> Iterator[_ResolvedTarget]: yield from self._resolve_computed_targets( - kind=signature.kind, + kind=kind, fields=fields, include_self=include_self, include_cross=include_cross, @@ -247,7 +293,7 @@ def _resolve_targets(self, *, signature: ChangeSignature) -> Iterator[_ResolvedT for display_target in derive_display_label_targets( display_labels=self.schema_branch.display_labels, - kind=signature.kind, + kind=kind, changed_fields=fields, include_self=include_self, include_cross=include_cross, @@ -263,7 +309,7 @@ def _resolve_targets(self, *, signature: ChangeSignature) -> Iterator[_ResolvedT for hfid_target in derive_hfid_targets( hfids=self.schema_branch.hfids, - kind=signature.kind, + kind=kind, changed_fields=fields, include_self=include_self, include_cross=include_cross, diff --git a/backend/infrahub/core/node/__init__.py b/backend/infrahub/core/node/__init__.py index b7a967fb7b5..d36677addfb 100644 --- a/backend/infrahub/core/node/__init__.py +++ b/backend/infrahub/core/node/__init__.py @@ -55,6 +55,7 @@ from infrahub.core.branch import Branch from infrahub.core.creation_context import NodeCreationContext + from infrahub.core.relationship import Relationship from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.database import InfrahubDatabase @@ -77,6 +78,25 @@ def relationship_fields(self) -> dict[str, set[str]]: ... log = get_logger() +JINJA2_ALLOWED_PATH_TYPES = ( + SchemaElementPathType.ATTR_WITH_PROP + | SchemaElementPathType.REL_ONE_MANDATORY_ATTR_WITH_PROP + | SchemaElementPathType.REL_ONE_OPTIONAL_ATTR_WITH_PROP +) + + +def _build_peer_stub(relationship: Relationship) -> dict[str, Any]: + """Build the minimal peer payload preloaded on a node's GraphQL response. + + The concrete kind is included when known so that a consumer resolving an + abstract GraphQL type does not have to hydrate the peer to learn it. + """ + stub: dict[str, Any] = {"id": relationship.peer_id} + peer_kind = relationship.get_concrete_peer_kind() + if peer_kind: + stub[KIND_GRAPHQL_FIELD_NAME] = peer_kind + return stub + class Node(BaseNode, MetadataInterface, metaclass=BaseNodeMeta): @classmethod @@ -706,6 +726,21 @@ async def _process_fields_attributes( return errors + def _has_pending_pool_dependency(self, schema_branch: SchemaBranch, jinja_template: InfrahubJinja2Template) -> bool: + """Whether the template reads a local pool-sourced attribute whose value is not allocated yet. + + Such a macro cannot be rendered until the pool allocation has taken place and must be skipped. + """ + for variable in jinja_template.get_variables(): + attribute_path = schema_branch.validate_schema_path( + node_schema=self._schema, path=variable, allowed_path_types=JINJA2_ALLOWED_PATH_TYPES + ) + if attribute_path.is_type_attribute: + attribute = self.get_attribute(attribute_path.active_attribute_schema.name) + if attribute.from_pool and attribute.value is None: + return True + return False + async def _resolve_jinja2_variables( self, db: InfrahubDatabase, @@ -713,16 +748,11 @@ async def _resolve_jinja2_variables( jinja_template: InfrahubJinja2Template, ) -> dict[str, Any]: """Resolve Jinja2 template variables from local attributes and relationship peers.""" - allowed_path_types = ( - SchemaElementPathType.ATTR_WITH_PROP - | SchemaElementPathType.REL_ONE_MANDATORY_ATTR_WITH_PROP - | SchemaElementPathType.REL_ONE_OPTIONAL_ATTR_WITH_PROP - ) variables: dict[str, Any] = {} for variable in jinja_template.get_variables(): attribute_path = schema_branch.validate_schema_path( - node_schema=self._schema, path=variable, allowed_path_types=allowed_path_types + node_schema=self._schema, path=variable, allowed_path_types=JINJA2_ALLOWED_PATH_TYPES ) if attribute_path.is_type_relationship: relationship = self.get_relationship(attribute_path.active_relationship_schema.name) @@ -740,6 +770,10 @@ async def _resolve_jinja2_variables( async def _process_macros(self, db: InfrahubDatabase) -> None: schema_branch = db.schema.get_schema_branch(self._branch.name) errors = [] + # Macros are iterated in dependency order, so a prerequisite is always seen before the + # macros that reference it. Skipping cascades: a macro whose dependency was skipped for an + # unallocated pool cannot render either and is deferred until the allocation happens. + skipped: set[str] = set() for macro in self._computed_jinja2_attributes: attr_schema = self._schema.get_attribute(name=macro) if not attr_schema.computed_attribute: @@ -754,10 +788,15 @@ async def _process_macros(self, db: InfrahubDatabase) -> None: continue jinja_template = InfrahubJinja2Template(template=attr_schema.computed_attribute.jinja2_template) + if jinja_template.get_referenced_root_fields() & skipped or self._has_pending_pool_dependency( + schema_branch=schema_branch, jinja_template=jinja_template + ): + skipped.add(macro) + continue + variables = await self._resolve_jinja2_variables( db=db, schema_branch=schema_branch, jinja_template=jinja_template ) - content = await jinja_template.render(variables=variables) generator_method_name = "_generate_attribute_default" @@ -809,18 +848,20 @@ async def _recompute_local_jinja2( jinja_template = InfrahubJinja2Template(template=attr_schema.computed_attribute.jinja2_template) - referenced_variables = jinja_template.get_variables() - depends_on_failed = any(var.split("__")[0] in failed_attributes for var in referenced_variables) - if depends_on_failed: + referenced_attributes = jinja_template.get_referenced_root_fields() + if failed_dependencies := referenced_attributes & failed_attributes: log.warning( "Skipping recomputation of Jinja2 attribute due to failed dependency", node_kind=self._schema.kind, attribute_name=target.attribute.name, - failed_dependencies=failed_attributes & {var.split("__")[0] for var in referenced_variables}, + failed_dependencies=failed_dependencies, ) failed_attributes.add(target.attribute.name) continue + if self._has_pending_pool_dependency(schema_branch=schema_branch, jinja_template=jinja_template): + continue + variables = await self._resolve_jinja2_variables( db=db, schema_branch=schema_branch, jinja_template=jinja_template ) @@ -1292,7 +1333,9 @@ async def to_graphql( peer_rels = list(rel_manager) if peer_rels: response[relationship_schema.name] = [ - {"node": {"id": relationship.peer_id}} for relationship in peer_rels if relationship.peer_id + {"node": _build_peer_stub(relationship=relationship)} + for relationship in peer_rels + if relationship.peer_id ] except LookupError: continue diff --git a/backend/infrahub/core/query/node.py b/backend/infrahub/core/query/node.py index 5e6e5576f3d..3e0bbe550a7 100644 --- a/backend/infrahub/core/query/node.py +++ b/backend/infrahub/core/query/node.py @@ -995,6 +995,7 @@ def __init__(self) -> None: self._metadata_map: dict[ tuple[str, str, RelationshipDirection, str], dict[MetadataOptions, Timestamp | str | bool | None] ] = {} + self._peer_kind_map: dict[str, str] = {} def add_peer( self, @@ -1002,6 +1003,7 @@ def add_peer( rel_name: str, peer_id: str, direction: RelationshipDirection, + peer_kind: str, created_at: Timestamp | None = None, created_by: str | None = None, updated_at: Timestamp | None = None, @@ -1014,6 +1016,7 @@ def add_peer( if direction not in self._rel_directions_map[node_id, rel_name]: self._rel_directions_map[node_id, rel_name][direction] = set() self._rel_directions_map[node_id, rel_name][direction].add(peer_id) + self._peer_kind_map[peer_id] = peer_kind key = (node_id, rel_name, direction, peer_id) provided = (created_at, created_by, updated_at, updated_by, source_id, owner_id, is_protected) if any(v is not None for v in provided): @@ -1053,6 +1056,9 @@ def get_metadata_map( ) -> dict[MetadataOptions, Timestamp | str | bool | None]: return self._metadata_map.get((node_id, rel_name, direction, peer_id), {}) + def get_peer_kind(self, peer_id: str) -> str: + return self._peer_kind_map[peer_id] + class NodeListGetRelationshipsQuery(Query): name: str = "node_list_get_relationship" @@ -1217,7 +1223,7 @@ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG WHERE r2.status = "active" RETURN r1, r2 } - RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "inbound" as direction, r1, r2 + RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, peer.kind AS peer_kind, "inbound" as direction, r1, r2 UNION WITH n MATCH (n)-[:IS_RELATED]->(rel:Relationship)-[:IS_RELATED]->(peer) @@ -1241,7 +1247,7 @@ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG WHERE r2.status = "active" RETURN r1, r2 } - RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "outbound" as direction, r1, r2 + RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, peer.kind AS peer_kind, "outbound" as direction, r1, r2 UNION WITH n MATCH (n)-[:IS_RELATED]->(rel:Relationship)<-[:IS_RELATED]-(peer) @@ -1265,13 +1271,13 @@ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG WHERE r2.status = "active" RETURN r1, r2 } - RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "bidirectional" as direction, r1, r2 + RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, peer.kind AS peer_kind, "bidirectional" as direction, r1, r2 } """ % {"filters": rels_filter} self.add_to_query(query) self.order_by = ["n_uuid", "rel_name", "peer_uuid", "direction"] - self.return_labels = ["n_uuid", "peer_uuid", "direction"] + self.return_labels = ["n_uuid", "peer_uuid", "peer_kind", "direction"] self._add_created_metadata_to_query() self._add_updated_metadata_to_query(branch_filter_str=rels_filter) @@ -1288,6 +1294,7 @@ def get_peers_group_by_node(self) -> GroupedPeerNodes: node_id = result.get("n_uuid") rel_name = result.get("rel_name") peer_id = result.get("peer_uuid") + peer_kind = result.get_as_type("peer_kind", return_type=str) direction = str(result.get("direction")) created_at = None @@ -1330,6 +1337,7 @@ def get_peers_group_by_node(self) -> GroupedPeerNodes: rel_name=rel_name, peer_id=peer_id, direction=direction_enum, + peer_kind=peer_kind, created_at=created_at, created_by=created_by_str, updated_at=updated_at, diff --git a/backend/infrahub/core/relationship/model.py b/backend/infrahub/core/relationship/model.py index e8c2701df0b..8d11bb938f3 100644 --- a/backend/infrahub/core/relationship/model.py +++ b/backend/infrahub/core/relationship/model.py @@ -88,6 +88,7 @@ class RelationshipUpdateDetails: @dataclass class PeerWithRelationshipMetadata: peer: Node | str + peer_kind: str | None = None created_at: Timestamp | None = None created_by: str | None = None updated_at: Timestamp | None = None @@ -139,6 +140,7 @@ def __init__( self._peer: Node | str | None = None self.peer_id: str | None = None self.peer_hfid: list[str] | None = None + self._resolved_peer_kind: str | None = None self.data: dict | RelationshipPeerData | str | Node | None = None self.from_pool: dict[str, Any] | None = None @@ -175,6 +177,13 @@ def get_peer_kind(self) -> str: return self._peer.get_kind() + def get_concrete_peer_kind(self) -> str | None: + """Return the peer's concrete kind, or None when only the schema's (possibly generic) peer kind is known.""" + if self._peer and not isinstance(self._peer, str): + return self._peer.get_kind() + + return self._resolved_peer_kind + @property def node_id(self) -> str: if self._node_id: @@ -222,6 +231,7 @@ def _get_updated_by(self) -> str | None: def _process_relationship_peer_data(self, data: RelationshipPeerData) -> None: self.set_peer(value=str(data.peer_id)) + self._resolved_peer_kind = data.peer_kind if not self.id and data.rel_node_id: self.id = data.rel_node_id @@ -262,6 +272,7 @@ def _process_dict_data(self, data: dict) -> None: def _process_peer_with_relationship_metadata(self, data: PeerWithRelationshipMetadata) -> None: self.set_peer(value=data.peer) + self._resolved_peer_kind = data.peer_kind self._set_created_at(data.created_at) self._set_created_by(data.created_by) self._set_updated_at(data.updated_at) diff --git a/backend/infrahub/core/utils.py b/backend/infrahub/core/utils.py index 23852f2da6f..63c71747b3d 100644 --- a/backend/infrahub/core/utils.py +++ b/backend/infrahub/core/utils.py @@ -160,7 +160,12 @@ def parse_node_kind(kind: str) -> NodeKind: def convert_ip_to_binary_str( - obj: ipaddress.IPv6Network | ipaddress.IPv4Network | ipaddress.IPv4Interface | ipaddress.IPv6Interface, + obj: ipaddress.IPv6Network + | ipaddress.IPv4Network + | ipaddress.IPv4Interface + | ipaddress.IPv6Interface + | ipaddress.IPv4Address + | ipaddress.IPv6Address, ) -> str: if isinstance(obj, ipaddress.IPv6Network | ipaddress.IPv4Network): prefix_bin = f"{int(obj.network_address):b}" diff --git a/backend/infrahub/core/validators/attribute/kind.py b/backend/infrahub/core/validators/attribute/kind.py index 2bf2f836f4b..c9d671616fa 100644 --- a/backend/infrahub/core/validators/attribute/kind.py +++ b/backend/infrahub/core/validators/attribute/kind.py @@ -12,6 +12,7 @@ from ..shared import AttributeSchemaValidatorQuery if TYPE_CHECKING: + from infrahub.core.attribute import BaseAttribute from infrahub.core.branch import Branch from infrahub.database import InfrahubDatabase @@ -75,6 +76,7 @@ async def get_paths(self) -> GroupedDataPaths: infrahub_attribute_class.validate_content( value=attr_value, name=self.attribute_schema.name, schema=self.attribute_schema ) + self._validate_value_is_canonical(value=attr_value, attribute_class=infrahub_attribute_class) except ValidationError: grouped_data_paths.add_data_path( DataPath( @@ -88,6 +90,22 @@ async def get_paths(self) -> GroupedDataPaths: ) return grouped_data_paths + def _validate_value_is_canonical(self, value: Any, attribute_class: type[BaseAttribute]) -> None: + """Reject a value that parses under the new kind but is not stored in that kind's canonical form. + + A kind change does not rewrite stored values, so a value such as ``10.0.0.1`` would survive a + change to ``IPHost`` while the canonical form is ``10.0.0.1/32``, leaving value filters and + uniqueness comparisons matching against a stale string. + + Raises: + ValidationError: The value is not canonical for the new kind + + """ + if attribute_class._normalize_value(value) != value: + raise ValidationError( + {self.attribute_schema.name: f"{value} is not stored as a valid {self.attribute_schema.kind}"} + ) + class AttributeKindChecker(ConstraintCheckerInterface): query_classes = [AttributeKindUpdateValidatorQuery] diff --git a/backend/infrahub/core/validators/constraint_merge.py b/backend/infrahub/core/validators/constraint_merge.py index 4c68ac68c12..4bd735ef46a 100644 --- a/backend/infrahub/core/validators/constraint_merge.py +++ b/backend/infrahub/core/validators/constraint_merge.py @@ -19,7 +19,9 @@ class ConstraintInfoMerger: def __init__(self, deduplicator: UniquenessConstraintDeduplicator) -> None: self.deduplicator = deduplicator - def merge(self, *constraint_lists: list[SchemaUpdateConstraintInfo]) -> list[SchemaUpdateConstraintInfo]: + def merge( + self, schema_branch: SchemaBranch, *constraint_lists: list[SchemaUpdateConstraintInfo] + ) -> list[SchemaUpdateConstraintInfo]: """Collapse the same constraint from multiple producers onto one entry. A constraint both broadened by a schema change (full population) and hit by a data change @@ -42,8 +44,8 @@ def merge(self, *constraint_lists: list[SchemaUpdateConstraintInfo]) -> list[Sch update={"node_uuids": sorted(set(existing.node_uuids) | set(constraint.node_uuids))} ) - return self.deduplicator.deduplicate(list(merged.values())) + return self.deduplicator.deduplicate(schema_branch=schema_branch, constraints=list(merged.values())) -def build_constraint_info_merger(schema_branch: SchemaBranch) -> ConstraintInfoMerger: - return ConstraintInfoMerger(deduplicator=UniquenessConstraintDeduplicator(schema_branch=schema_branch)) +def build_constraint_info_merger() -> ConstraintInfoMerger: + return ConstraintInfoMerger(deduplicator=UniquenessConstraintDeduplicator()) diff --git a/backend/infrahub/core/validators/determiner.py b/backend/infrahub/core/validators/determiner.py index 5092443daa3..ae0550a3fde 100644 --- a/backend/infrahub/core/validators/determiner.py +++ b/backend/infrahub/core/validators/determiner.py @@ -31,16 +31,14 @@ class ConstraintValidatorDeterminer: def __init__( self, - schema_branch: SchemaBranch, node_diff_index: NodeDiffIndex, uniqueness_scoper: UniquenessConstraintScoper, ) -> None: - self.schema_branch = schema_branch self.node_diff_index = node_diff_index self.uniqueness_scoper = uniqueness_scoper async def get_constraints( - self, node_diffs: list[NodeDiffFieldSummary], filter_invalid: bool = True + self, schema_branch: SchemaBranch, node_diffs: list[NodeDiffFieldSummary], filter_invalid: bool = True ) -> list[SchemaUpdateConstraintInfo]: self.node_diff_index.initialize(node_diffs) self.uniqueness_scoper.reset() @@ -48,10 +46,10 @@ async def get_constraints( if not node_diffs: return constraints - constraints.extend(await self._get_property_constraints_for_impacted_kinds()) + constraints.extend(await self._get_property_constraints_for_impacted_kinds(schema_branch=schema_branch)) for kind in self.node_diff_index.kinds: - schema = self._get_schema_or_none(kind=kind) + schema = self._get_schema_or_none(schema_branch=schema_branch, kind=kind) if schema is None: # a branch can hold data changes for a kind whose schema it also deletes LOG.info("Skipping constraints for kind absent from the schema", kind=kind) @@ -80,13 +78,13 @@ async def _get_constraints_for_one_schema(self, schema: MainSchemaTypes) -> list constraints.extend(await self._get_relationship_constraints_for_one_schema(schema=schema)) return constraints - def _get_schema_or_none(self, kind: str) -> MainSchemaTypes | None: + def _get_schema_or_none(self, schema_branch: SchemaBranch, kind: str) -> MainSchemaTypes | None: try: - return self.schema_branch.get(name=kind, duplicate=False) + return schema_branch.get(name=kind, duplicate=False) except SchemaNotFoundError: return None - def _get_impacted_kinds(self) -> set[str]: + def _get_impacted_kinds(self, schema_branch: SchemaBranch) -> set[str]: """Kinds with node-level property constraints that could be violated by the data diff. Includes the kinds present in the diff and the generics they inherit from, since a @@ -94,14 +92,16 @@ def _get_impacted_kinds(self) -> set[str]: """ kinds: set[str] = set() for kind in self.node_diff_index.kinds: - schema = self._get_schema_or_none(kind=kind) + schema = self._get_schema_or_none(schema_branch=schema_branch, kind=kind) if schema is None: continue kinds.add(kind) kinds.update(getattr(schema, "inherit_from", None) or []) return kinds - def _node_property_triggered_by_diff(self, schema: MainSchemaTypes, prop_name: str) -> bool: + def _node_property_triggered_by_diff( + self, schema_branch: SchemaBranch, schema: MainSchemaTypes, prop_name: str + ) -> bool: """Return True if the diff touches a field guarded by the node-level property `prop_name`. A node-level constraint may be defined on a kind without every data change to that kind @@ -109,31 +109,35 @@ def _node_property_triggered_by_diff(self, schema: MainSchemaTypes, prop_name: s properties default to emitting so a newly-added node-level constraint is never missed. """ if prop_name == "uniqueness_constraints": - return self.uniqueness_scoper.requires_validation(schema=schema) + return self.uniqueness_scoper.requires_validation(schema_branch=schema_branch, schema=schema) if prop_name in ("parent", "children"): return self.node_diff_index.has_relationship_diff(kind=schema.kind, name=prop_name) return True - async def _get_property_constraints_for_impacted_kinds(self) -> list[SchemaUpdateConstraintInfo]: - impacted_kinds = self._get_impacted_kinds() + async def _get_property_constraints_for_impacted_kinds( + self, schema_branch: SchemaBranch + ) -> list[SchemaUpdateConstraintInfo]: + impacted_kinds = self._get_impacted_kinds(schema_branch=schema_branch) schemas: list[MainSchemaTypes] = [] for kind in impacted_kinds: - schema = self._get_schema_or_none(kind=kind) + schema = self._get_schema_or_none(schema_branch=schema_branch, kind=kind) if schema is not None: schemas.append(schema) - for schema in self.schema_branch.get_all(duplicate=False).values(): + for schema in schema_branch.get_all(duplicate=False).values(): if schema.kind in impacted_kinds: continue - if self.uniqueness_scoper.requires_validation(schema=schema): + if self.uniqueness_scoper.requires_validation(schema_branch=schema_branch, schema=schema): schemas.append(schema) constraints: list[SchemaUpdateConstraintInfo] = [] for schema in schemas: - constraints.extend(await self._get_property_constraints_for_one_schema(schema=schema)) + constraints.extend( + await self._get_property_constraints_for_one_schema(schema_branch=schema_branch, schema=schema) + ) return constraints async def _get_property_constraints_for_one_schema( - self, schema: MainSchemaTypes + self, schema_branch: SchemaBranch, schema: MainSchemaTypes ) -> list[SchemaUpdateConstraintInfo]: constraints: list[SchemaUpdateConstraintInfo] = [] for prop_name, prop_field_info in schema.__class__.model_fields.items(): @@ -174,14 +178,18 @@ async def _get_property_constraints_for_one_schema( if not do_constraint_validation: continue - if not self._node_property_triggered_by_diff(schema=schema, prop_name=prop_name): + if not self._node_property_triggered_by_diff( + schema_branch=schema_branch, schema=schema, prop_name=prop_name + ): # the node-level constraint is defined on this kind, but no field it guards is in # the diff, so a data change cannot violate it continue node_uuids: list[str] | None = None if prop_name == "uniqueness_constraints": - node_uuids = await self.uniqueness_scoper.affected_node_uuids(schema=schema) + node_uuids = await self.uniqueness_scoper.affected_node_uuids( + schema_branch=schema_branch, schema=schema + ) constraints.append( SchemaUpdateConstraintInfo(constraint_name=constraint_name, path=schema_path, node_uuids=node_uuids) @@ -270,16 +278,12 @@ async def _get_constraints_for_one_field( def build_constraint_validator_determiner( db: InfrahubDatabase, branch: Branch, - schema_branch: SchemaBranch, at: Timestamp | str | None = None, ) -> ConstraintValidatorDeterminer: """Wire a determiner with its node-diff index and uniqueness scoper for a single operation.""" node_diff_index = NodeDiffIndex() uniqueness_scoper = UniquenessConstraintScoper( - schema_branch=schema_branch, dependent_resolver=UniquenessDependentResolver(db=db, branch=branch, at=at), node_diff_index=node_diff_index, ) - return ConstraintValidatorDeterminer( - schema_branch=schema_branch, node_diff_index=node_diff_index, uniqueness_scoper=uniqueness_scoper - ) + return ConstraintValidatorDeterminer(node_diff_index=node_diff_index, uniqueness_scoper=uniqueness_scoper) diff --git a/backend/infrahub/core/validators/uniqueness/deduplicator.py b/backend/infrahub/core/validators/uniqueness/deduplicator.py index 8a26463c13e..110a148311d 100644 --- a/backend/infrahub/core/validators/uniqueness/deduplicator.py +++ b/backend/infrahub/core/validators/uniqueness/deduplicator.py @@ -24,10 +24,9 @@ class UniquenessConstraintDeduplicator: (full-population) check is ever lost. """ - def __init__(self, schema_branch: SchemaBranch) -> None: - self.schema_branch = schema_branch - - def deduplicate(self, constraints: list[SchemaUpdateConstraintInfo]) -> list[SchemaUpdateConstraintInfo]: + def deduplicate( + self, schema_branch: SchemaBranch, constraints: list[SchemaUpdateConstraintInfo] + ) -> list[SchemaUpdateConstraintInfo]: uniqueness_infos = { constraint.path.schema_kind: constraint for constraint in constraints @@ -40,7 +39,9 @@ def deduplicate(self, constraints: list[SchemaUpdateConstraintInfo]) -> list[Sch redundant_kinds = { kind for kind, info in uniqueness_infos.items() - if self._is_covered_by_generic(kind=kind, info=info, uniqueness_infos=uniqueness_infos) + if self._is_covered_by_generic( + schema_branch=schema_branch, kind=kind, info=info, uniqueness_infos=uniqueness_infos + ) } if not redundant_kinds: return list(constraints) @@ -53,9 +54,13 @@ def deduplicate(self, constraints: list[SchemaUpdateConstraintInfo]) -> list[Sch ] def _is_covered_by_generic( - self, kind: str, info: SchemaUpdateConstraintInfo, uniqueness_infos: dict[str, SchemaUpdateConstraintInfo] + self, + schema_branch: SchemaBranch, + kind: str, + info: SchemaUpdateConstraintInfo, + uniqueness_infos: dict[str, SchemaUpdateConstraintInfo], ) -> bool: - schema = self._get_schema_or_none(kind=kind) + schema = self._get_schema_or_none(schema_branch=schema_branch, kind=kind) if schema is None or isinstance(schema, GenericSchema): # a generic is the coverer, never the covered return False @@ -70,7 +75,7 @@ def _is_covered_by_generic( continue if not self._scope_covers(node_info=info, generic_info=generic_info): continue - generic_schema = self._get_schema_or_none(kind=generic_kind) + generic_schema = self._get_schema_or_none(schema_branch=schema_branch, kind=generic_kind) if generic_schema is None: continue covered_groups |= self._constraint_groups(generic_schema) @@ -92,8 +97,8 @@ def _scope_covers(self, node_info: SchemaUpdateConstraintInfo, generic_info: Sch def _constraint_groups(self, schema: MainSchemaTypes) -> set[frozenset[str]]: return {frozenset(group) for group in schema.uniqueness_constraints or []} - def _get_schema_or_none(self, kind: str) -> MainSchemaTypes | None: + def _get_schema_or_none(self, schema_branch: SchemaBranch, kind: str) -> MainSchemaTypes | None: try: - return self.schema_branch.get(name=kind, duplicate=False) + return schema_branch.get(name=kind, duplicate=False) except SchemaNotFoundError: return None diff --git a/backend/infrahub/core/validators/uniqueness/scope.py b/backend/infrahub/core/validators/uniqueness/scope.py index 85e0733f031..9fe050391c6 100644 --- a/backend/infrahub/core/validators/uniqueness/scope.py +++ b/backend/infrahub/core/validators/uniqueness/scope.py @@ -72,26 +72,25 @@ class UniquenessConstraintScoper: def __init__( self, - schema_branch: SchemaBranch, dependent_resolver: UniquenessDependentResolverInterface, node_diff_index: NodeDiffIndex, ) -> None: - self.schema_branch = schema_branch self.dependent_resolver = dependent_resolver self.node_diff_index = node_diff_index # scopes are recomputed for the same kind across the trigger check and the uuid resolution; - # the cache is valid only for the node-diff index's current contents + # the cache is valid only for the node-diff index's current contents and the schema branch + # the scopes were computed against self._scope_cache: dict[str, UniquenessScopeForKind] = {} def reset(self) -> None: """Drop cached scopes so the next lookup recomputes against the current node-diff index.""" self._scope_cache = {} - def requires_validation(self, schema: MainSchemaTypes) -> bool: - return self._scope(schema=schema).requires_validation + def requires_validation(self, schema_branch: SchemaBranch, schema: MainSchemaTypes) -> bool: + return self._scope(schema_branch=schema_branch, schema=schema).requires_validation - async def affected_node_uuids(self, schema: MainSchemaTypes) -> list[str] | None: - scope = self._scope(schema=schema) + async def affected_node_uuids(self, schema_branch: SchemaBranch, schema: MainSchemaTypes) -> list[str] | None: + scope = self._scope(schema_branch=schema_branch, schema=schema) node_uuids = set(scope.object_uuids) for peer_change in scope.cross_kind_peer_changes: if not peer_change.changed_peer_uuids: @@ -135,14 +134,14 @@ def _uuids_for_field(self, kinds: set[str], field_name: str, is_relationship: bo uuids |= self.node_diff_index.get_uuids_for_attribute(kind=kind, name=field_name) return uuids - def _scope(self, schema: MainSchemaTypes) -> UniquenessScopeForKind: + def _scope(self, schema_branch: SchemaBranch, schema: MainSchemaTypes) -> UniquenessScopeForKind: cached = self._scope_cache.get(schema.kind) if cached is None: - cached = self._compute_scope(schema=schema) + cached = self._compute_scope(schema_branch=schema_branch, schema=schema) self._scope_cache[schema.kind] = cached return cached - def _compute_scope(self, schema: MainSchemaTypes) -> UniquenessScopeForKind: + def _compute_scope(self, schema_branch: SchemaBranch, schema: MainSchemaTypes) -> UniquenessScopeForKind: """Compute why and how a data change implicates `schema`'s uniqueness. Uniqueness spans single unique attributes and multi-field constraint groups, each element of @@ -151,7 +150,7 @@ def _compute_scope(self, schema: MainSchemaTypes) -> UniquenessScopeForKind: """ fragments = [ *self._unique_attribute_fragments(schema=schema), - *self._uniqueness_constraint_fragments(schema=schema), + *self._uniqueness_constraint_fragments(schema_branch=schema_branch, schema=schema), ] return UniquenessScopeForKind( requires_validation=bool(fragments), @@ -168,12 +167,14 @@ def _unique_attribute_fragments(self, schema: MainSchemaTypes) -> list[Uniquenes ) return [fragment for fragment in fragments if fragment is not None] - def _uniqueness_constraint_fragments(self, schema: MainSchemaTypes) -> list[UniquenessScopeFragment]: + def _uniqueness_constraint_fragments( + self, schema_branch: SchemaBranch, schema: MainSchemaTypes + ) -> list[UniquenessScopeFragment]: fragments: list[UniquenessScopeFragment] = [] for constraint_group in schema.uniqueness_constraints or []: for constraint_path in constraint_group: try: - schema_path = schema.parse_schema_path(path=constraint_path, schema=self.schema_branch) + schema_path = schema.parse_schema_path(path=constraint_path, schema=schema_branch) except AttributePathParsingError: LOG.warning(f"Cannot parse {schema.kind}.uniqueness_constraints element '{constraint_path}'") continue diff --git a/backend/infrahub/errors/catalogue.py b/backend/infrahub/errors/catalogue.py index c34f6c9b5fa..e9438ee297c 100644 --- a/backend/infrahub/errors/catalogue.py +++ b/backend/infrahub/errors/catalogue.py @@ -178,7 +178,7 @@ class CatalogueEntry(BaseModel): CatalogueEntry( description=( "The write was rejected because a previous branch merge failed and left the default " - "branch protected. Recovery is required: an administrator must run `infrahub recover`. " + "branch protected. Recovery is required: an administrator must run `infrahub recover merge`. " "Unlike MERGE_IN_PROGRESS this is not retryable." ), stability="evolving", diff --git a/backend/infrahub/git/fingerprint/composer.py b/backend/infrahub/git/fingerprint/composer.py index 3d72724a46f..c99f4b8ffc5 100644 --- a/backend/infrahub/git/fingerprint/composer.py +++ b/backend/infrahub/git/fingerprint/composer.py @@ -19,21 +19,36 @@ from infrahub.git.fingerprint.blob_resolver import BlobResolver -def fold_commit_id(*, commit: str, watch: InfrahubWatchConfig | None, closure_complete: bool) -> str | None: - """Return the commit id to fold into a fingerprint, or None to omit it. - - The commit id is folded (making the fingerprint change on every commit, the safe - over-regenerating default) whenever the definition's inputs cannot be pinned down: - - - `watch` is absent (`None`): the definition has not declared its dependencies. - - the dependency closure is incomplete: some output-affecting dependency could not be - resolved, so hashing only the resolved paths would leave the fingerprint stable over - an input the system already knows is unknown - an under-regeneration risk. - - Only a definition with a present `watch` and a complete closure omits the commit id and - gets a stable, precise fingerprint. +def fold_commit_id( + *, commit: str, watch: InfrahubWatchConfig | None, closure_complete: bool, watch_required: bool +) -> str | None: + """Return the commit id to mix into a fingerprint, or None to leave it out. + + Mixing the commit id in makes the fingerprint change on every commit: the definition is + regenerated more often than it needs to be, but a change is never missed. Leaving it out + makes the fingerprint change only when one of the listed dependencies changes. So the + commit id goes in whenever we cannot be sure the dependency list names every file that + affects the definition's output. + + `closure_complete=False` means the dependency scan gave up on at least one reference, so + the system already knows a file is missing from the list. The commit id always goes in. + + When the list is complete, how far that can be trusted depends on how it was built, which + the caller states through `watch_required`: + + - `watch_required=True` - the list is a directory listing rather than a real dependency + scan: every tracked file that happens to sit next to the entry point. "Complete" only + means the listing succeeded, so a helper imported from another directory is missing from + the list without anything noticing. Only a `watch` declaration, where the author names + the extra files by hand, is trusted to close the list; without one the commit id goes in. + - `watch_required=False` - the list was built by parsing the source and following every + reference it declares, and any reference that could not be followed already set + `closure_complete=False`. A complete list is therefore trustworthy by itself, and no + `watch` declaration is needed to leave the commit id out. """ - if watch is None or not closure_complete: + if not closure_complete: + return commit + if watch_required and watch is None: return commit return None @@ -148,8 +163,16 @@ def compose_transformation( case PythonTransformationFingerprintInput(): terms.append(f"class_name={inputs.class_name}") terms.append(f"convert_query_response={inputs.convert_query_response}") + # A Python transform's dependencies are the files sitting next to its source + # file, so an import from anywhere else is absent from the list: the author has + # to name those files in `watch` before the fingerprint can drop the commit id. + watch_required = True case Jinja2TransformationFingerprintInput(): terms.append(f"template_path={inputs.template_path}") + # A Jinja2 transform's dependencies come from parsing the template and following + # every include/import/extends it declares, so a complete list already names + # every file that affects the rendered output. + watch_required = False case _: # pragma: no cover - exhaustiveness guard for a new transform kind assert_never(inputs) @@ -157,6 +180,7 @@ def compose_transformation( watch=inputs.watch, closure_complete=inputs.dependencies_complete, upstream_resolved=query_fingerprint is not None, + watch_required=watch_required, ) if commit_term is not None: terms.append(f"commit_id={commit_term}") @@ -194,6 +218,7 @@ def compose_generator_definition(self, inputs: GeneratorDefinitionFingerprintInp watch=inputs.watch, closure_complete=inputs.dependencies_complete, upstream_resolved=query_fingerprint is not None, + watch_required=True, ) if commit_term is not None: terms.append(f"commit_id={commit_term}") @@ -203,7 +228,12 @@ def compose_generator_definition(self, inputs: GeneratorDefinitionFingerprintInp return fingerprint def _resolve_commit_term( - self, *, watch: InfrahubWatchConfig | None, closure_complete: bool, upstream_resolved: bool + self, + *, + watch: InfrahubWatchConfig | None, + closure_complete: bool, + upstream_resolved: bool, + watch_required: bool, ) -> str | None: """Return the commit-id term for a transform/generator, or None to omit it. @@ -212,7 +242,12 @@ def _resolve_commit_term( the commit id is folded in and the fingerprint can never be stable over an upstream it could not read. """ - return fold_commit_id(commit=self._commit, watch=watch, closure_complete=closure_complete and upstream_resolved) + return fold_commit_id( + commit=self._commit, + watch=watch, + closure_complete=closure_complete and upstream_resolved, + watch_required=watch_required, + ) def _closure_term(self, dependencies: Iterable[str]) -> str: hashed_paths = self._closure_selector.select(dependencies) diff --git a/backend/infrahub/git/integrator.py b/backend/infrahub/git/integrator.py index 659e5429bc6..b244a82d42c 100644 --- a/backend/infrahub/git/integrator.py +++ b/backend/infrahub/git/integrator.py @@ -33,6 +33,7 @@ InfrahubRepositoryConfig, InfrahubWatchConfig, ) +from infrahub_sdk.schema.validate import validate_schema as validate_write_schema from infrahub_sdk.spec.menu import MenuFile from infrahub_sdk.spec.object import ObjectFile from infrahub_sdk.template import Jinja2Template @@ -563,7 +564,7 @@ async def compare_jinja2_transform( ) -> bool: if ( existing_transform.description.value != local_transform.description - or existing_transform.template_path.value != local_transform.template_path + or existing_transform.template_path.value != str(local_transform.template_path) or existing_transform.query.id != local_transform.query or existing_transform.dependencies.value != local_transform.dependencies or existing_transform.dependencies_complete.value != local_transform.dependencies_complete @@ -865,14 +866,16 @@ async def import_schema_files(self, branch_name: str, commit: str, config_file: # Valid data format of content for schema_file in schemas_data: - try: - self.sdk.schema.validate(schema_file.content) - except PydanticValidationError as exc: - log.error(f"Schema not valid, found '{len(exc.errors())}' error(s) in {schema_file.identifier} : {exc}") - raise ValidationError( - identifier=str(self.id), - message=f"Schema not valid, found '{len(exc.errors())}' error(s) in {schema_file.identifier} : {exc}", - ) from exc + result = validate_write_schema(schema=schema_file.content or {}) + for warning in result.warnings: + log.warning(f"{schema_file.identifier}: {warning.message}") + if not result.valid: + message = ( + f"Schema not valid, found '{len(result.errors)}' error(s) in " + f"{schema_file.identifier} : {'; '.join(result.messages)}" + ) + log.error(message) + raise ValidationError(identifier=str(self.id), message=message) response = await self.sdk.schema.load( schemas=[item.content for item in schemas_data], branch=branch_name, wait_until_converged=True diff --git a/backend/infrahub/git/repository.py b/backend/infrahub/git/repository.py index b8cf355a3af..34da295967b 100644 --- a/backend/infrahub/git/repository.py +++ b/backend/infrahub/git/repository.py @@ -14,6 +14,7 @@ from pydantic import Field from infrahub import config +from infrahub.core.branch.enums import TERMINAL_BRANCH_STATUSES from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus, RepositoryOperationalStatus from infrahub.exceptions import ( CommitNotFoundError, @@ -159,6 +160,10 @@ async def collect_pending_imports(self, staging_branch: str | None = None) -> Co # TODO need to handle properly the situation when a branch is not valid. if self.internal_status == RepositoryInternalStatus.ACTIVE.value: + # A branch that has been merged (or is being deleted) is read-only: recording its commit + # would be rejected by the graph and abort the whole sync, so drop those branches here. + new_branches, updated_branches = await self._exclude_read_only_branches(new_branches, updated_branches) + for branch_name in new_branches: is_valid = self.validate_remote_branch(branch_name=branch_name) if not is_valid: @@ -226,6 +231,22 @@ async def collect_pending_imports(self, staging_branch: str | None = None) -> Co ) return CollectedImports(imports=imports, failed_imports=failed_imports) + async def _exclude_read_only_branches( + self, new_branches: list[str], updated_branches: list[str] + ) -> tuple[list[str], list[str]]: + """Drop branches whose Infrahub branch is in a terminal status (merged or being deleted). + + Such branches are read-only, so recording their commit is rejected by the graph. The default + branch is never terminal, so filtering here does not affect the staging-import path. + """ + terminal_status_values = {status.value for status in TERMINAL_BRANCH_STATUSES} + graph_branches = await self.sdk.branch.all() + read_only = {name for name, branch in graph_branches.items() if branch.status.value in terminal_status_values} + return ( + [name for name in new_branches if self._get_mapped_target_branch(branch_name=name) not in read_only], + [name for name in updated_branches if self._get_mapped_target_branch(branch_name=name) not in read_only], + ) + async def _collect_staging_imports( self, staging_branch: str | None, updated_branches: list[str] ) -> list[PendingObjectImport]: diff --git a/backend/infrahub/graphql/queries/branch.py b/backend/infrahub/graphql/queries/branch.py index 18a8c504e34..ed968f29e7d 100644 --- a/backend/infrahub/graphql/queries/branch.py +++ b/backend/infrahub/graphql/queries/branch.py @@ -135,6 +135,7 @@ async def infrahub_branch_resolver( graphql_context=info.context, branch_filters=branch_filters, node_ordering=node_ordering, + exclude_global=True, ) if "default_branch" in fields: diff --git a/backend/infrahub/graphql/resolvers/single_relationship.py b/backend/infrahub/graphql/resolvers/single_relationship.py index b134b9a6386..a821d7aaf10 100644 --- a/backend/infrahub/graphql/resolvers/single_relationship.py +++ b/backend/infrahub/graphql/resolvers/single_relationship.py @@ -10,8 +10,10 @@ from infrahub.core.node import Node from infrahub.core.relationship import Relationship from infrahub.core.schema.relationship_schema import RelationshipSchema +from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase +from infrahub.graphql.constants import KIND_GRAPHQL_FIELD_NAME from infrahub.graphql.field_extractor import extract_graphql_fields from infrahub.graphql.metadata import build_metadata_query_options, get_metadata_options_from_fields @@ -28,6 +30,41 @@ class SingleRelationshipResolver: def __init__(self) -> None: self._data_loader_instances: dict[GetManyParams, NodeDataLoader] = {} + def _build_id_only_node( + self, + rel_schema: RelationshipSchema, + parent: dict[str, Any], + schema_branch: SchemaBranch, + node_fields: dict[str, Any], + property_fields: dict[str, Any], + metadata_fields: dict[str, Any], + ) -> dict[str, Any] | None: + """Build the peer payload for an id-only query straight from the parent's preloaded data. + + The cardinality-one peer ID is already loaded on the parent, so a query + wanting nothing but that ID can be answered without hydrating the peer. + Returns None whenever that is not possible and the caller has to hydrate. + """ + if set(node_fields) != {"id"} or property_fields or any(metadata_fields.values()): + return None + + try: + peer_stub = parent[rel_schema.name][0]["node"] + peer_id = peer_stub["id"] + except (KeyError, IndexError): + return None + + if rel_schema.peer not in schema_branch.generics: + return {"id": peer_id} + + # A generic peer resolves to a GraphQL interface, whose type resolution + # needs the peer's concrete kind. + peer_kind = peer_stub.get(KIND_GRAPHQL_FIELD_NAME) + if not peer_kind: + return None + + return {"id": peer_id, KIND_GRAPHQL_FIELD_NAME: peer_kind} + def _build_relationship_meta_response( self, relationship: Relationship, metadata_fields: dict[str, Any] ) -> dict[str, Any]: @@ -85,6 +122,21 @@ async def resolve(self, parent: dict, info: GraphQLResolveInfo, **kwargs: Any) - response: dict[str, Any] = {"node": None, "properties": {}} + schema_branch = graphql_context.db.schema.get_schema_branch(name=graphql_context.branch.name) + id_only_node = self._build_id_only_node( + rel_schema=node_rel, + parent=parent, + schema_branch=schema_branch, + node_fields=node_fields, + property_fields=property_fields, + metadata_fields=metadata_fields, + ) + if id_only_node is not None: + response["node"] = id_only_node + if graphql_context.related_node_ids is not None: + graphql_context.related_node_ids.add(id_only_node["id"]) + return response + relationship: Relationship | None = None peer_node: Node | None = None diff --git a/backend/infrahub/graphql/types/__init__.py b/backend/infrahub/graphql/types/__init__.py index e87286c853a..2015f7e07d9 100644 --- a/backend/infrahub/graphql/types/__init__.py +++ b/backend/infrahub/graphql/types/__init__.py @@ -9,6 +9,7 @@ DropdownFields, DropdownType, IntAttributeType, + IPAddressType, IPHostType, IPNetworkType, JSONAttributeType, @@ -40,6 +41,7 @@ "CheckboxAttributeType", "DropdownFields", "DropdownType", + "IPAddressType", "IPHostType", "IPNetworkType", "InfrahubBranch", diff --git a/backend/infrahub/graphql/types/attribute.py b/backend/infrahub/graphql/types/attribute.py index 0a8a92b46d0..cd1439b3145 100644 --- a/backend/infrahub/graphql/types/attribute.py +++ b/backend/infrahub/graphql/types/attribute.py @@ -145,6 +145,16 @@ class Meta: interfaces = {AttributeInterface} +class IPAddressType(BaseAttribute): + value = Field(String) + version = Field(Int) + + class Meta: + description = "Attribute of type IPAddress" + name = "IPAddress" + interfaces = {AttributeInterface} + + class MacAddressType(BaseAttribute): value = Field(String) oui = Field(String) diff --git a/backend/infrahub/proposed_change/tasks.py b/backend/infrahub/proposed_change/tasks.py index 7ff4929a618..b52fd1cd7d3 100644 --- a/backend/infrahub/proposed_change/tasks.py +++ b/backend/infrahub/proposed_change/tasks.py @@ -552,8 +552,8 @@ async def run_proposed_change_schema_integrity_check(model: RequestProposedChang db=database, schema=candidate_schema, diff_summary=diff_summary, branch=source_branch ) constraints_from_schema_diff = validation_result.constraints - merger = build_constraint_info_merger(schema_branch=candidate_schema) - constraints = merger.merge(constraints_from_data_diff, constraints_from_schema_diff) + merger = build_constraint_info_merger() + constraints = merger.merge(candidate_schema, constraints_from_data_diff, constraints_from_schema_diff) if not constraints: return @@ -608,8 +608,10 @@ async def _get_proposed_change_schema_integrity_constraints( field_summary.add_attribute_node_uuid(name=element_name, node_uuid=node_id) async with db.start_session(read_only=True) as session_db: - determiner = build_constraint_validator_determiner(db=session_db, branch=branch, schema_branch=schema) - return await determiner.get_constraints(node_diffs=list(node_diff_field_summary_map.values())) + determiner = build_constraint_validator_determiner(db=session_db, branch=branch) + return await determiner.get_constraints( + schema_branch=schema, node_diffs=list(node_diff_field_summary_map.values()) + ) @flow(name="proposed-changed-repository-checks", flow_run_name="Process user defined checks") diff --git a/backend/infrahub/telemetry/constants.py b/backend/infrahub/telemetry/constants.py index 4b9980b96bb..fa81e3c9c49 100644 --- a/backend/infrahub/telemetry/constants.py +++ b/backend/infrahub/telemetry/constants.py @@ -1,7 +1,7 @@ from enum import StrEnum TELEMETRY_KIND: str = "community" -TELEMETRY_VERSION: str = "20250318" +TELEMETRY_VERSION: str = "20260628" class RemoteSendStatus(StrEnum): diff --git a/backend/infrahub/telemetry/database.py b/backend/infrahub/telemetry/database.py index 080b4de645c..e214d92f9e8 100644 --- a/backend/infrahub/telemetry/database.py +++ b/backend/infrahub/telemetry/database.py @@ -2,12 +2,17 @@ from prefect import task from prefect.cache_policies import NONE -from infrahub.core import utils +from infrahub.core import registry, utils +from infrahub.core.constants import InfrahubKind from infrahub.core.graph.schema import GRAPH_SCHEMA +from infrahub.core.manager import NodeManager from infrahub.core.query import QueryType +from infrahub.core.schema import NodeSchema from infrahub.database import DatabaseType, InfrahubDatabase from .models import TelemetryDatabaseData, TelemetryDatabaseServerData, TelemetryDatabaseSystemInfoData +from .queries import CountNodesByKindsQuery +from .utils import safe_metric async def get_server_info(db: InfrahubDatabase) -> list[TelemetryDatabaseServerData]: @@ -47,8 +52,31 @@ async def get_system_info(db: InfrahubDatabase) -> TelemetryDatabaseSystemInfoDa ) +async def count_corenode(db: InfrahubDatabase) -> int: + """Count managed (CoreNode) nodes on the default branch.""" + return await NodeManager.count(db=db, schema=InfrahubKind.NODE) + + +async def count_user_nodes(db: InfrahubDatabase) -> int: + """Count concrete nodes in user-editable namespaces, excluding group-generic kinds.""" + default_branch = registry.get_branch_from_registry() + schema_branch = db.schema.get_schema_branch(name=default_branch.name) + user_namespaces = [namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable] + schemas = [ + node_schema + for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces) + if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from + ] + if not schemas: + return 0 + query = await CountNodesByKindsQuery.init(db=db, branch=default_branch, schemas=schemas) + await query.execute(db=db) + return sum(item.count for item in query.get_data()) + + @task(name="telemetry-gather-db", task_run_name="Gather Database Information", cache_policy=NONE) async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabaseData: + """Gather node/relationship counts and database server/system info.""" async with db.start_session(read_only=True) as dbs: server_info = [] system_info = None @@ -58,8 +86,7 @@ async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabase server_info = await get_server_info(db=dbs) system_info = await get_system_info(db=dbs) - # server_info is only available on Neo4j Enterprise - # so if it's not empty, we can assume the database is of type Enterprise + # server_info is populated only on Neo4j Enterprise, so a non-empty result implies it. if len(server_info) == 0: database_type = f"{database_type}-community" else: @@ -83,4 +110,8 @@ async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabase for name in GRAPH_SCHEMA["nodes"]: data.node_count[name] = await utils.count_nodes(db=dbs, label=name) + # corenode/user each degrade to None independently through the shared metric helper. + data.node_count["corenode"] = await safe_metric(count_corenode(db=dbs)) + data.node_count["user"] = await safe_metric(count_user_nodes(db=dbs)) + return data diff --git a/backend/infrahub/telemetry/models.py b/backend/infrahub/telemetry/models.py index 1c2d78866e6..f2e6a1a4a7f 100644 --- a/backend/infrahub/telemetry/models.py +++ b/backend/infrahub/telemetry/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from .constants import InfrahubType @@ -10,6 +10,27 @@ class TelemetryWorkerData(BaseModel): class TelemetryBranchData(BaseModel): total: int + active: int | None = None + + +class TelemetryAccountData(BaseModel): + active: int | None = Field(default=None) + groups: int | None = Field(default=None) + + +class TelemetryActivity24hData(BaseModel): + logins: int | None = Field(default=None) + unique_logins: int | None = Field(default=None) + checks_started: int | None = Field(default=None) + checks_passed: int | None = Field(default=None) + checks_failed: int | None = Field(default=None) + artifacts_created: int | None = Field(default=None) + artifacts_updated: int | None = Field(default=None) + branches_created: int | None = Field(default=None) + branches_merged: int | None = Field(default=None) + branches_deleted: int | None = Field(default=None) + webhooks_fired_success: int | None = Field(default=None) + webhooks_fired_failure: int | None = Field(default=None) class TelemetrySchemaData(BaseModel): @@ -32,7 +53,7 @@ class TelemetryDatabaseSystemInfoData(BaseModel): class TelemetryDatabaseData(BaseModel): database_type: str relationship_count: dict[str, int] - node_count: dict[str, int] + node_count: dict[str, int | None] servers: list[TelemetryDatabaseServerData] system_info: TelemetryDatabaseSystemInfoData | None @@ -59,6 +80,8 @@ class TelemetryData(BaseModel): platform: str workers: TelemetryWorkerData branches: TelemetryBranchData + accounts: TelemetryAccountData = Field(default_factory=TelemetryAccountData) + activity_24h: TelemetryActivity24hData = Field(default_factory=TelemetryActivity24hData) features: dict[str, int] schema_info: TelemetrySchemaData database: TelemetryDatabaseData diff --git a/backend/infrahub/telemetry/queries.py b/backend/infrahub/telemetry/queries.py index b547b4dc31d..6d7ba79c42c 100644 --- a/backend/infrahub/telemetry/queries.py +++ b/backend/infrahub/telemetry/queries.py @@ -1,10 +1,15 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from infrahub.core.query import Query, QueryType from infrahub.core.query.standard_node import StandardNodeGetListQuery if TYPE_CHECKING: + from collections.abc import Generator + + from infrahub.core.schema import NodeSchema from infrahub.database import InfrahubDatabase @@ -28,6 +33,65 @@ def _normalize_end_date(value: str) -> str: return value +@dataclass(frozen=True) +class NodeKindCount: + kind: str + count: int + + +class CountNodesByKindsQuery(Query): + """Count active nodes of the given concrete kinds on the query's branch at the query's time. + + One pass over the graph replaces a per-kind count query fan-out; kinds with no + active node return no row. + + Concrete node schemas only: the match is on the vertex ``kind`` property, which + always holds the node's concrete kind. A generic kind never appears there (it is + carried only in the vertex labels), so matching a generic would silently count + zero. Supporting generics would require matching on labels instead, and a sum over + label matches double-counts nodes inheriting several of the requested kinds. + """ + + name = "count-nodes-by-kinds" + type = QueryType.READ + insert_return = False + + def __init__(self, schemas: list[NodeSchema], **kwargs: Any) -> None: + self.kinds = [schema.kind for schema in schemas] + super().__init__(**kwargs) + + async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002 + branch_filter, branch_params = self.branch.get_query_filter_path(at=self.at) + self.params.update(branch_params) + self.params["kinds"] = self.kinds + + query = """ + MATCH (n:Node) + WHERE n.kind IN $kinds + CALL (n) { + MATCH (n)-[r:IS_PART_OF]->(:Root) + WHERE %(branch_filter)s + RETURN r + // r.status is a tie-breaker for nodes added/deleted at the same time + ORDER BY r.branch_level DESC, r.from DESC, r.status ASC + LIMIT 1 + } + WITH n, r + WHERE r.status = "active" + RETURN n.kind AS kind, count(n) AS total + ORDER BY kind + """ % {"branch_filter": branch_filter} + self.add_to_query(query) + self.update_return_labels(["kind", "total"]) + + def get_data(self) -> Generator[NodeKindCount, None, None]: + for result in self.get_results(): + yield NodeKindCount( + kind=result.get_as_type("kind", str), + count=result.get_as_type("total", int), + ) + + class TelemetrySnapshotGetListQuery(StandardNodeGetListQuery): name = "telemetry-snapshot-get-list" diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 7e12b47d37f..3f2369a6c97 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -1,16 +1,43 @@ +from datetime import datetime from typing import Any from prefect import task from prefect.cache_policies import NONE from prefect.client.orchestration import PrefectClient, get_client -from prefect.client.schemas.objects import WorkerStatus - +from prefect.client.schemas.filters import ( + FlowFilter, + FlowFilterName, + FlowRunFilter, + FlowRunFilterStartTime, + FlowRunFilterState, + FlowRunFilterStateType, +) +from prefect.client.schemas.objects import StateType, WorkerStatus +from prefect.types import DateTime + +from infrahub.events.account_action import AccountLoggedInEvent +from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent +from infrahub.events.branch_action import BranchCreatedEvent, BranchDeletedEvent, BranchMergedEvent from infrahub.events.utils import get_all_events +from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent from infrahub.trigger.constants import NAME_SEPARATOR from infrahub.trigger.models import TriggerType from infrahub.trigger.setup import gather_all_automations +from infrahub.workflows.catalogue import WEBHOOK_PROCESS + +from .models import TelemetryActivity24hData, TelemetryPrefectData, TelemetryWorkPoolData +from .utils import get_activity_window, inclusive_end, safe_metric + +WEBHOOK_FLOW_NAME = WEBHOOK_PROCESS.name +WEBHOOK_FAILURE_STATES = [StateType.FAILED, StateType.CRASHED] + -from .models import TelemetryPrefectData, TelemetryWorkPoolData +async def _post_count_by(client: PrefectClient, path: str, payload: dict[str, Any]) -> list[Any]: + """POST a Prefect count-by query and return its buckets (empty when the response has none).""" + response = await client._client.post(path, json=payload) + response.raise_for_status() + data = response.json() + return data if isinstance(data, list) else [] @task(name="telemetry-gather-work-pools", task_run_name="Gather Work Pools", cache_policy=NONE) @@ -37,21 +64,118 @@ async def gather_prefect_events(client: PrefectClient) -> dict[str, Any]: infrahub_events = get_all_events() events: dict[str, int] = {} - async def count_events(event_name: str) -> int: - payload = {"filter": {"event": {"name": [event_name]}}} - response = await client._client.post("/events/count-by/event", json=payload) - response.raise_for_status() - data = response.json() - if not isinstance(data, list) or len(data) == 0: - return 0 - return data[0]["count"] - for event in infrahub_events: - events[event.event_name] = await count_events(event_name=event.event_name) + payload = {"filter": {"event": {"name": [event.event_name]}}} + buckets = await _post_count_by(client=client, path="/events/count-by/event", payload=payload) + events[event.event_name] = sum(bucket.get("count", 0) for bucket in buckets) return events +def _windowed_event_filter(event_name: str, window_start: datetime, window_end: datetime) -> dict[str, Any]: + """Build the count-by filter for the half-open window ``[window_start, window_end)``.""" + return { + "filter": { + "event": {"name": [event_name]}, + "occurred": {"since": window_start.isoformat(), "until": inclusive_end(window_end).isoformat()}, + } + } + + +@task(name="telemetry-gather-windowed-event", task_run_name="Gather Windowed Event Count", cache_policy=NONE) +async def count_windowed_event( + client: PrefectClient, event_name: str, window_start: datetime, window_end: datetime +) -> int: + """Count events of one name that occurred within ``[window_start, window_end)``.""" + payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) + buckets = await _post_count_by(client=client, path="/events/count-by/event", payload=payload) + return sum(bucket.get("count", 0) for bucket in buckets) + + +@task(name="telemetry-gather-windowed-unique", task_run_name="Gather Windowed Unique Count", cache_policy=NONE) +async def count_windowed_unique_resources( + client: PrefectClient, event_name: str, window_start: datetime, window_end: datetime +) -> int: + """Count distinct resources emitting one event within ``[window_start, window_end)``.""" + payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) + buckets = await _post_count_by(client=client, path="/events/count-by/resource", payload=payload) + return len(buckets) + + +@task(name="telemetry-gather-webhook-runs", task_run_name="Gather Webhook Runs", cache_policy=NONE) +async def count_webhook_runs(client: PrefectClient, window_start: datetime, window_end: datetime) -> tuple[int, int]: + """Return ``(success, failure)`` webhook flow-run counts started within the window. + + Success = terminal ``COMPLETED``; failure = terminal ``FAILED``/``CRASHED``; non-terminal + runs count in neither. + """ + flow_filter = FlowFilter(name=FlowFilterName(any_=[WEBHOOK_FLOW_NAME])) + after = DateTime.fromisoformat(window_start.isoformat()) + before = DateTime.fromisoformat(inclusive_end(window_end).isoformat()) + + def runs_in_states(states: list[StateType]) -> FlowRunFilter: + return FlowRunFilter( + start_time=FlowRunFilterStartTime(after_=after, before_=before), + state=FlowRunFilterState(type=FlowRunFilterStateType(any_=states)), + ) + + # count_flow_runs returns a server-side count. read_flow_runs is deliberately not used here: + # it pages at the API limit and would undercount a busy day. + success = await client.count_flow_runs( + flow_filter=flow_filter, flow_run_filter=runs_in_states([StateType.COMPLETED]) + ) + failure = await client.count_flow_runs( + flow_filter=flow_filter, flow_run_filter=runs_in_states(WEBHOOK_FAILURE_STATES) + ) + return success, failure + + +@task(name="telemetry-gather-activity-24h", task_run_name="Gather 24h Activity", cache_policy=NONE) +async def gather_activity_24h(client: PrefectClient) -> TelemetryActivity24hData: + """Assemble the 24h activity metrics over the previous full UTC calendar day.""" + window_start, window_end = get_activity_window() + + async def windowed_count(event_name: str) -> int | None: + return await safe_metric( + count_windowed_event.fn( + client=client, + event_name=event_name, + window_start=window_start, + window_end=window_end, + ) + ) + + logins = await windowed_count(AccountLoggedInEvent.event_name) + unique_logins = await safe_metric( + count_windowed_unique_resources.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=window_start, + window_end=window_end, + ) + ) + webhook_counts = await safe_metric( + count_webhook_runs.fn(client=client, window_start=window_start, window_end=window_end) + ) + webhooks_fired_success = webhook_counts[0] if webhook_counts is not None else None + webhooks_fired_failure = webhook_counts[1] if webhook_counts is not None else None + + return TelemetryActivity24hData( + logins=logins, + unique_logins=unique_logins, + checks_started=await windowed_count(ValidatorStartedEvent.event_name), + checks_passed=await windowed_count(ValidatorPassedEvent.event_name), + checks_failed=await windowed_count(ValidatorFailedEvent.event_name), + artifacts_created=await windowed_count(ArtifactCreatedEvent.event_name), + artifacts_updated=await windowed_count(ArtifactUpdatedEvent.event_name), + branches_created=await windowed_count(BranchCreatedEvent.event_name), + branches_merged=await windowed_count(BranchMergedEvent.event_name), + branches_deleted=await windowed_count(BranchDeletedEvent.event_name), + webhooks_fired_success=webhooks_fired_success, + webhooks_fired_failure=webhooks_fired_failure, + ) + + @task(name="telemetry-gather-automations", task_run_name="Gather Automations", cache_policy=NONE) async def gather_prefect_automations(client: PrefectClient) -> dict[str, Any]: automations = await gather_all_automations(client=client) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 216af63f6c3..745361d2ac9 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -2,16 +2,20 @@ import json import platform import time -from typing import Any +from typing import Any, Protocol from prefect import flow, task from prefect.cache_policies import NONE +from prefect.client.orchestration import get_client as get_prefect_client from prefect.logging import get_run_logger from infrahub import __version__, config from infrahub.core import registry, utils from infrahub.core.branch import Branch -from infrahub.core.constants import InfrahubKind +from infrahub.core.constants import AccountStatus, InfrahubKind +from infrahub.core.manager import NodeManager +from infrahub.database import InfrahubDatabase +from infrahub.services.component import InfrahubComponent from infrahub.workers.dependencies import get_component, get_database, get_http from .constants import ( @@ -20,11 +24,18 @@ RemoteSendStatus, ) from .database import gather_database_information -from .models import TelemetryBranchData, TelemetryData, TelemetrySchemaData, TelemetryWorkerData +from .models import ( + TelemetryAccountData, + TelemetryActivity24hData, + TelemetryBranchData, + TelemetryData, + TelemetrySchemaData, + TelemetryWorkerData, +) from .repository import TelemetrySnapshotRepository from .snapshot import TelemetrySnapshot -from .task_manager import gather_prefect_information -from .utils import determine_infrahub_type +from .task_manager import gather_activity_24h, gather_prefect_information +from .utils import determine_infrahub_type, safe_metric @task(name="telemetry-schema-information", task_run_name="Gather Schema Information", cache_policy=NONE) @@ -59,37 +70,127 @@ async def gather_feature_information() -> dict[str, int]: return data -@task(name="telemetry-gather-data", task_run_name="Gather Anonynous Data", cache_policy=NONE) -async def gather_anonymous_telemetry_data() -> TelemetryData: - start_time = time.time() - +@task(name="telemetry-account-information", task_run_name="Gather Account Information", cache_policy=NONE) +async def gather_account_information(db: InfrahubDatabase) -> TelemetryAccountData: + """Gather active-account and account-group counts on the default branch.""" default_branch = registry.get_branch_from_registry() - component = await get_component() - workers = await component.list_workers(branch=default_branch.name, schema_hash=False) - data = TelemetryData( - deployment_id=registry.id, - execution_time=None, - infrahub_version=__version__, - infrahub_type=determine_infrahub_type(), - python_version=platform.python_version(), - platform=platform.machine(), - workers=TelemetryWorkerData( - total=len(workers), - active=len([w for w in workers if w.active]), - ), - branches=TelemetryBranchData( - total=len(registry.branch), - ), - features=await gather_feature_information(), - schema_info=await gather_schema_information(branch=default_branch), - database=await gather_database_information(db=await get_database()), - prefect=await gather_prefect_information(), + active = await safe_metric( + NodeManager.count( + db=db, + schema=InfrahubKind.ACCOUNT, + filters={"status__value": AccountStatus.ACTIVE.value}, + branch=default_branch, + ) ) + groups = await safe_metric( + NodeManager.count( + db=db, + schema=InfrahubKind.ACCOUNTGROUP, + branch=default_branch, + ) + ) + + return TelemetryAccountData(active=active, groups=groups) + + +async def count_active_branches(db: InfrahubDatabase) -> int: + """Count open non-system branches (excludes the default, global, and terminal branches).""" + return await Branch.get_list_count(db=db, exclude_global=True, exclude_default=True, exclude_terminal=True) + + +class GathererInterface[T](Protocol): + async def gather(self) -> T: ... + + +class DefaultAccountGatherer: + def __init__(self, db: InfrahubDatabase) -> None: + self.db = db + + async def gather(self) -> TelemetryAccountData: + return await gather_account_information(db=self.db) + + +class DefaultActivityGatherer: + async def gather(self) -> TelemetryActivity24hData: + async with get_prefect_client(sync_client=False) as prefect_client: + return await gather_activity_24h(client=prefect_client) + - data.execution_time = time.time() - start_time +class DefaultActiveBranchCounter: + def __init__(self, db: InfrahubDatabase) -> None: + self.db = db - return data + async def gather(self) -> int: + return await count_active_branches(db=self.db) + + +class AnonymousTelemetryGatherer: + """Assemble the full telemetry payload from its injected metric sources.""" + + def __init__( + self, + *, + database: InfrahubDatabase, + component: InfrahubComponent, + account_gatherer: GathererInterface[TelemetryAccountData], + activity_gatherer: GathererInterface[TelemetryActivity24hData], + active_branch_counter: GathererInterface[int], + ) -> None: + self.database = database + self.component = component + self.account_gatherer = account_gatherer + self.activity_gatherer = activity_gatherer + self.active_branch_counter = active_branch_counter + + async def gather(self) -> TelemetryData: + start_time = time.time() + + default_branch = registry.get_branch_from_registry() + workers = await self.component.list_workers(branch=default_branch.name, schema_hash=False) + + accounts = await safe_metric(self.account_gatherer.gather()) + activity_24h = await safe_metric(self.activity_gatherer.gather()) + + data = TelemetryData( + deployment_id=registry.id, + execution_time=None, + infrahub_version=__version__, + infrahub_type=determine_infrahub_type(), + python_version=platform.python_version(), + platform=platform.machine(), + workers=TelemetryWorkerData( + total=len(workers), + active=len([w for w in workers if w.active]), + ), + branches=TelemetryBranchData( + total=len(registry.branch), + active=await safe_metric(self.active_branch_counter.gather()), + ), + accounts=accounts if accounts is not None else TelemetryAccountData(), + activity_24h=activity_24h if activity_24h is not None else TelemetryActivity24hData(), + features=await gather_feature_information(), + schema_info=await gather_schema_information(branch=default_branch), + database=await gather_database_information(db=self.database), + prefect=await gather_prefect_information(), + ) + + data.execution_time = time.time() - start_time + + return data + + +async def build_anonymous_telemetry_gatherer() -> AnonymousTelemetryGatherer: + """Wire the telemetry gatherer with its real collaborators.""" + database = await get_database() + component = await get_component() + return AnonymousTelemetryGatherer( + database=database, + component=component, + account_gatherer=DefaultAccountGatherer(db=database), + activity_gatherer=DefaultActivityGatherer(), + active_branch_counter=DefaultActiveBranchCounter(db=database), + ) @task(name="telemetry-post-data", task_run_name="Upload data", retries=5, cache_policy=NONE) @@ -104,7 +205,8 @@ async def send_telemetry_push() -> None: log = get_run_logger() log.info("Gathering anonymous telemetry data...") - data = await gather_anonymous_telemetry_data() + gatherer = await build_anonymous_telemetry_gatherer() + data = await gatherer.gather() data_dict = data.model_dump(mode="json") checksum = hashlib.sha256(json.dumps(data_dict).encode()).hexdigest() log.info(f"Anonymous usage telemetry gathered in {data.execution_time} seconds.") @@ -130,7 +232,6 @@ async def send_telemetry_push() -> None: log.warning(f"Failed to store telemetry snapshot locally: {exc}") return - # Conditionally send remotely if config.SETTINGS.main.telemetry_optout: log.info("User opted out of remote telemetry. Marking snapshot as skipped.") snapshot.remote_send_status = RemoteSendStatus.SKIPPED @@ -153,7 +254,6 @@ async def send_telemetry_push() -> None: snapshot.remote_send_status = RemoteSendStatus.FAILED log.warning(f"Failed to send telemetry data to remote endpoint: {exc}") - # Update remote send status in DB try: await repository.save(snapshot) except Exception as exc: diff --git a/backend/infrahub/telemetry/utils.py b/backend/infrahub/telemetry/utils.py index dac6385b2ec..015b0dd6bcc 100644 --- a/backend/infrahub/telemetry/utils.py +++ b/backend/infrahub/telemetry/utils.py @@ -1,7 +1,15 @@ import importlib.metadata +from collections.abc import Awaitable +from datetime import UTC, datetime, timedelta + +from infrahub.log import get_run_logger from .constants import InfrahubType +log = get_run_logger() + +WINDOW_LENGTH = timedelta(hours=24) + def determine_infrahub_type() -> InfrahubType: try: @@ -9,3 +17,34 @@ def determine_infrahub_type() -> InfrahubType: return InfrahubType.ENTERPRISE except importlib.metadata.PackageNotFoundError: return InfrahubType.COMMUNITY + + +async def safe_metric[T](coro: Awaitable[T]) -> T | None: + """Await ``coro`` and return its result, or ``None`` if it raises (the error is logged). + + A falsy result such as ``0`` is returned as-is; only an exception maps to ``None``. + """ + try: + return await coro + except Exception as exc: + log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) + return None + + +def floor_to_midnight_utc(moment: datetime) -> datetime: + """Return 00:00:00 UTC of the calendar day containing ``moment``.""" + in_utc = moment.astimezone(UTC) + return in_utc.replace(hour=0, minute=0, second=0, microsecond=0) + + +def get_activity_window(now: datetime | None = None) -> tuple[datetime, datetime]: + """Return the half-open ``[window_start, window_end)`` for the previous full UTC day.""" + reference = now if now is not None else datetime.now(tz=UTC) + window_end = floor_to_midnight_utc(reference) + window_start = window_end - WINDOW_LENGTH + return window_start, window_end + + +def inclusive_end(window_end: datetime) -> datetime: + """Return the last instant inside the half-open window: ``window_end`` minus one microsecond.""" + return window_end - timedelta(microseconds=1) diff --git a/backend/infrahub/types.py b/backend/infrahub/types.py index afbc9faea61..47588018b16 100644 --- a/backend/infrahub/types.py +++ b/backend/infrahub/types.py @@ -269,6 +269,16 @@ class IPNetwork(InfrahubDataType): infrahub = "IPNetwork" +class IPAddress(InfrahubDataType): + label: str = "IPAddress" + graphql = graphene.String + graphql_query = "IPAddressType" + graphql_create = "TextAttributeCreate" + graphql_update = "TextAttributeUpdate" + graphql_filter = graphene.String + infrahub = "IPAddress" + + class Boolean(InfrahubDataType): label: str = "Boolean" graphql = graphene.Boolean @@ -337,6 +347,7 @@ class Any(InfrahubDataType): "Bandwidth": Bandwidth, "IPHost": IPHost, "IPNetwork": IPNetwork, + "IPAddress": IPAddress, "Boolean": Boolean, "Checkbox": Checkbox, "List": List, @@ -362,6 +373,7 @@ class Any(InfrahubDataType): "Bandwidth": float, # Bandwidth in some units, represented as a float "IPHost": IPvAnyAddress, # type: ignore[dict-item] "IPNetwork": str, + "IPAddress": IPvAnyAddress, # type: ignore[dict-item] "Boolean": bool, "Checkbox": bool, # Checkboxes represent boolean values "List": list[Any], # Lists can contain any type of items diff --git a/backend/templates/generate_protocols.j2 b/backend/templates/generate_protocols.j2 index 27c53ce59f5..95e16f7fa7e 100644 --- a/backend/templates/generate_protocols.j2 +++ b/backend/templates/generate_protocols.j2 @@ -8,7 +8,7 @@ from infrahub.core.protocols_base import CoreNode if TYPE_CHECKING: from enum import Enum - from infrahub.core.attribute import Boolean, DateTime, Dropdown, HashedPassword, Integer, IPHost, IPNetwork, JSONAttribute, ListAttribute, String, URL, BooleanOptional, DateTimeOptional, DropdownOptional, HashedPasswordOptional, IntegerOptional, IPHostOptional, IPNetworkOptional, JSONAttributeOptional, ListAttributeOptional, StringOptional, URLOptional + from infrahub.core.attribute import Boolean, DateTime, Dropdown, HashedPassword, Integer, IPAddress, IPHost, IPNetwork, JSONAttribute, ListAttribute, String, URL, BooleanOptional, DateTimeOptional, DropdownOptional, HashedPasswordOptional, IntegerOptional, IPAddressOptional, IPHostOptional, IPNetworkOptional, JSONAttributeOptional, ListAttributeOptional, StringOptional, URLOptional from infrahub.core.relationship import RelationshipManager diff --git a/backend/templates/generate_protocols_sdk.j2 b/backend/templates/generate_protocols_sdk.j2 index c482e913906..e6d41322885 100644 --- a/backend/templates/generate_protocols_sdk.j2 +++ b/backend/templates/generate_protocols_sdk.j2 @@ -9,7 +9,7 @@ from .protocols_base import CoreNode, CoreNodeSync if TYPE_CHECKING: from datetime import datetime from infrahub_sdk.node import RelatedNodeSync, RelationshipManagerSync, RelatedNode, RelationshipManager - from .protocols_base import String, StringOptional, Integer, IntegerOptional, Boolean, BooleanOptional, URL, URLOptional, Dropdown, DropdownOptional, Enum, EnumOptional, DateTime, DateTimeOptional, IPHost, IPHostOptional, IPNetwork, IPNetworkOptional, HashedPassword, HashedPasswordOptional, JSONAttribute, JSONAttributeOptional, ListAttribute, ListAttributeOptional + from .protocols_base import String, StringOptional, Integer, IntegerOptional, Boolean, BooleanOptional, URL, URLOptional, Dropdown, DropdownOptional, Enum, EnumOptional, DateTime, DateTimeOptional, IPAddress, IPAddressOptional, IPHost, IPHostOptional, IPNetwork, IPNetworkOptional, HashedPassword, HashedPasswordOptional, JSONAttribute, JSONAttributeOptional, ListAttribute, ListAttributeOptional # --------------------------------------------- diff --git a/backend/tests/benchmark/test_graphql_query.py b/backend/tests/benchmark/test_graphql_query.py index ef7a52d7f55..7a1791aca8d 100644 --- a/backend/tests/benchmark/test_graphql_query.py +++ b/backend/tests/benchmark/test_graphql_query.py @@ -213,6 +213,58 @@ def test_query_rel_one( ) +def test_query_rel_one_id_only( + exec_async: Callable[..., Any], + aio_benchmark: Callable[..., Any], + db: InfrahubDatabase, + default_branch: Branch, + dataset04: None, +) -> None: + query = """ + query { + CoreGraphQLQuery { + count + edges { + node { + id + display_label + name { + value + } + repository { + node { + id + } + } + } + } + } + } + """ + + default_branch.update_schema_hash() + gql_params = exec_async(prepare_graphql_params, db=db, branch=default_branch) + + for _ in range(NBR_WARMUP): + exec_async( + graphql, + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + aio_benchmark( + graphql, + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + # @pytest.mark.xfail(reason="Disabling for now but it's not producing consistent results") # def test_query_rel_one_filter_rel_many(aio_benchmark, db: InfrahubDatabase, default_branch: Branch, dataset04): # query = """ diff --git a/backend/tests/component/conftest.py b/backend/tests/component/conftest.py index 85df33f8a71..d8e71e942e2 100644 --- a/backend/tests/component/conftest.py +++ b/backend/tests/component/conftest.py @@ -1658,6 +1658,7 @@ async def all_attribute_types_schema( {"name": "myjson", "kind": "JSON", "optional": True}, {"name": "ipaddress", "kind": "IPHost", "optional": True}, {"name": "prefix", "kind": "IPNetwork", "optional": True}, + {"name": "bare_address", "kind": "IPAddress", "optional": True}, ], } diff --git a/backend/tests/component/core/constraint_validators/test_attribute_kind_update.py b/backend/tests/component/core/constraint_validators/test_attribute_kind_update.py index aabb4a3e7d3..e00ed6a058f 100644 --- a/backend/tests/component/core/constraint_validators/test_attribute_kind_update.py +++ b/backend/tests/component/core/constraint_validators/test_attribute_kind_update.py @@ -174,6 +174,144 @@ async def test_query_update_on_branch_with_too_large_value( ) +async def _get_kind_change_data_paths( + db: InfrahubDatabase, branch: Branch, node_schema: NodeSchema, field_name: str, new_kind: str +) -> set[DataPath]: + attr = node_schema.get_attribute(name=field_name) + attr.kind = new_kind + registry.schema.set(name=node_schema.kind, schema=node_schema, branch=branch.name) + + schema_path = SchemaPath(path_type=SchemaPathType.ATTRIBUTE, schema_kind=node_schema.kind, field_name=field_name) + query = await AttributeKindUpdateValidatorQuery.init( + db=db, branch=branch, node_schema=node_schema, schema_path=schema_path + ) + await query.execute(db=db) + + return set((await query.get_paths()).get_all_data_paths()) + + +async def test_query_iphost_to_ipaddress_is_blocked( + db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema +) -> None: + """An IPHost value carries a prefix, which is not a valid IPAddress, so the change is refused.""" + node = await Node.init(db=db, schema=all_attribute_types_schema.kind, branch=default_branch) + await node.new(db=db, name="host", ipaddress="10.0.0.1/32") + await node.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=all_attribute_types_schema, + field_name="ipaddress", + new_kind="IPAddress", + ) + + assert all_data_paths == { + DataPath( + branch=default_branch.name, + path_type=PathType.ATTRIBUTE, + node_id=node.id, + kind=all_attribute_types_schema.kind, + field_name="ipaddress", + value="10.0.0.1/32", + ) + } + + +async def test_query_ipaddress_to_iphost_is_blocked( + db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema +) -> None: + """A bare address parses as an IPHost but is not canonical for it, and no migration rewrites it.""" + node = await Node.init(db=db, schema=all_attribute_types_schema.kind, branch=default_branch) + await node.new(db=db, name="host", bare_address="10.0.0.1") + await node.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=all_attribute_types_schema, + field_name="bare_address", + new_kind="IPHost", + ) + + assert all_data_paths == { + DataPath( + branch=default_branch.name, + path_type=PathType.ATTRIBUTE, + node_id=node.id, + kind=all_attribute_types_schema.kind, + field_name="bare_address", + value="10.0.0.1", + ) + } + + +async def test_query_iphost_to_ipnetwork_with_canonical_values_is_allowed( + db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema +) -> None: + """A value already canonical for the new IP kind is left alone, the check is not block-everything.""" + node = await Node.init(db=db, schema=all_attribute_types_schema.kind, branch=default_branch) + await node.new(db=db, name="net", ipaddress="10.0.0.0/24") + await node.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=all_attribute_types_schema, + field_name="ipaddress", + new_kind="IPNetwork", + ) + + assert all_data_paths == set() + + +async def test_query_text_to_macaddress_with_non_canonical_value_is_blocked( + db: InfrahubDatabase, default_branch: Branch, car_accord_main: Node +) -> None: + """MacAddress normalizes too, so a dash-delimited value is refused rather than left un-rewritten.""" + car = await NodeManager.get_one(db=db, branch=default_branch, id=car_accord_main.id) + car.get_attribute("name").value = "aa-bb-cc-dd-ee-ff" + await car.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=registry.schema.get_node_schema(name="TestCar", branch=default_branch), + field_name="name", + new_kind="MacAddress", + ) + + assert all_data_paths == { + DataPath( + branch=default_branch.name, + path_type=PathType.ATTRIBUTE, + node_id=car_accord_main.id, + kind="TestCar", + field_name="name", + value="aa-bb-cc-dd-ee-ff", + ) + } + + +async def test_query_text_to_macaddress_with_canonical_value_is_allowed( + db: InfrahubDatabase, default_branch: Branch, car_accord_main: Node +) -> None: + """An already-canonical MacAddress passes, so the check does not block every conversion.""" + car = await NodeManager.get_one(db=db, branch=default_branch, id=car_accord_main.id) + car.get_attribute("name").value = "AA:BB:CC:DD:EE:FF" + await car.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=registry.schema.get_node_schema(name="TestCar", branch=default_branch), + field_name="name", + new_kind="MacAddress", + ) + + assert all_data_paths == set() + + async def test_query_update_on_branch_with_parameters_violation( db: InfrahubDatabase, default_branch: Branch, diff --git a/backend/tests/component/core/constraint_validators/test_determiner.py b/backend/tests/component/core/constraint_validators/test_determiner.py index cfe2cb6bda8..60848f29281 100644 --- a/backend/tests/component/core/constraint_validators/test_determiner.py +++ b/backend/tests/component/core/constraint_validators/test_determiner.py @@ -35,14 +35,10 @@ async def resolve( return set() -def _build_determiner(schema_branch: SchemaBranch) -> ConstraintValidatorDeterminer: +def _build_determiner() -> ConstraintValidatorDeterminer: node_diff_index = NodeDiffIndex() - scoper = UniquenessConstraintScoper( - schema_branch=schema_branch, dependent_resolver=_NoDependentsResolver(), node_diff_index=node_diff_index - ) - return ConstraintValidatorDeterminer( - schema_branch=schema_branch, node_diff_index=node_diff_index, uniqueness_scoper=scoper - ) + scoper = UniquenessConstraintScoper(dependent_resolver=_NoDependentsResolver(), node_diff_index=node_diff_index) + return ConstraintValidatorDeterminer(node_diff_index=node_diff_index, uniqueness_scoper=scoper) def node_constraint(kind: str, property_name: str) -> SchemaUpdateConstraintInfo: @@ -190,9 +186,9 @@ def person_cars_node_diff( class TestConstraintDeterminer: async def test_no_node_diffs(self, car_person_schema: SchemaBranch, default_branch: Branch) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() - constraints = await determiner.get_constraints(node_diffs=[]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[]) assert constraints == [] @@ -203,10 +199,10 @@ async def test_one_attribute_update_node_diff( person_name_node_diff: tuple[NodeDiffFieldSummary, set[SchemaUpdateConstraintInfo]], ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) relevant_constraints = [ c @@ -224,10 +220,10 @@ async def test_many_relationship_update( person_cars_node_diff: tuple[NodeDiffFieldSummary, set[SchemaUpdateConstraintInfo]], ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_cars_node_diff - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert len(constraints) == len(constraint_info_set) assert constraint_info_set == set(constraints) @@ -245,7 +241,7 @@ async def test_node_property_constraints_included( name_attr_schema.parameters.max_length = 30 car_schema = schema_branch.get(name="TestCar", duplicate=False) car_schema.uniqueness_constraints = [["owner", "color__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff max_length_param_constraint_info = SchemaUpdateConstraintInfo( constraint_name=ConstraintIdentifier.ATTRIBUTE_PARAMETERS_MAX_LENGTH_UPDATE.value, @@ -259,7 +255,7 @@ async def test_node_property_constraints_included( constraint_info_set.add(node_uniqueness_constraint("TestPerson", node_uuids=[CHANGED_PERSON_UUID])) constraint_info_set.add(max_length_param_constraint_info) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert set(constraints) == constraint_info_set @@ -272,14 +268,14 @@ async def test_uniqueness_constraint_on_peer_attribute_included( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) car_schema = schema_branch.get(name="TestCar", duplicate=False) car_schema.uniqueness_constraints = [["owner__name", "color__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff constraint_info_set.add(node_uniqueness_constraint("TestPerson", node_uuids=[CHANGED_PERSON_UUID])) # TestCar's constraint reads the name attribute of the related TestPerson, so a TestPerson # data change can violate it even though TestCar itself has no diff. constraint_info_set.add(node_uniqueness_constraint("TestCar")) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert set(constraints) == constraint_info_set @@ -291,7 +287,7 @@ async def test_uniqueness_not_triggered_by_unrelated_field( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) generic_schema = schema_branch.get(name="TestCar", duplicate=False) generic_schema.uniqueness_constraints = [["name__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff = NodeDiffFieldSummary(kind="TestElectricCar", attribute_node_uuids={"nbr_engine": set()}) # nbr_engine participates in no uniqueness path, so the uniqueness check must not be # triggered on the implementation or on its generic; only the nbr_engine field constraints remain @@ -301,7 +297,7 @@ async def test_uniqueness_not_triggered_by_unrelated_field( attribute_constraint("TestElectricCar", "nbr_engine", "unique"), } - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert set(constraints) == constraint_info_set @@ -313,7 +309,7 @@ async def test_generic_uniqueness_triggered_by_inherited_field( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) generic_schema = schema_branch.get(name="TestCar", duplicate=False) generic_schema.uniqueness_constraints = [["name__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # `name` is inherited from the generic; a generic-level uniqueness check spans every # implementing node, so changing name on an implementation must trigger the check on the # generic (TestCar) as well as on the implementation (TestElectricCar) @@ -321,7 +317,7 @@ async def test_generic_uniqueness_triggered_by_inherited_field( kind="TestElectricCar", attribute_node_uuids={"name": {CHANGED_ELECTRIC_CAR_UUID}} ) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) # both the generic and the implementation checks are scoped to the changed implementation node expected = { @@ -344,10 +340,10 @@ async def test_uniqueness_triggered_by_generic_peer_implementation( # reading the peer's `name` must fire when an implementation of that generic (LocationSite) # changes `name`, even though the peer kind named in the path (LocationGeneric) has no diff thing_schema.uniqueness_constraints = [["location__name"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff = NodeDiffFieldSummary(kind="LocationSite", attribute_node_uuids={"name": set()}) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert node_uniqueness_constraint("TestThing") in set(constraints) @@ -359,13 +355,13 @@ async def test_uniqueness_not_triggered_by_unrelated_peer_attribute( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) car_schema = schema_branch.get(name="TestCar", duplicate=False) car_schema.uniqueness_constraints = [["owner__name", "color__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # TestCar's uniqueness constraints read TestPerson.name; a change to an unrelated # TestPerson attribute, height, does not participate, so neither kind's uniqueness # check should trigger node_diff = NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"height": set()}) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) constraint_set = set(constraints) assert node_uniqueness_constraint("TestCar") not in constraint_set @@ -378,12 +374,13 @@ async def test_kind_missing_from_schema_is_skipped( person_name_node_diff: tuple[NodeDiffFieldSummary, set[SchemaUpdateConstraintInfo]], ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff constraint_info_set.add(node_uniqueness_constraint("TestPerson", node_uuids=[CHANGED_PERSON_UUID])) constraints = await determiner.get_constraints( - node_diffs=[NodeDiffFieldSummary(kind="TestDeleted", attribute_node_uuids={"name": set()}), node_diff] + schema_branch=schema_branch, + node_diffs=[NodeDiffFieldSummary(kind="TestDeleted", attribute_node_uuids={"name": set()}), node_diff], ) # TestDeleted is absent from the schema, so it contributes nothing; only TestPerson remains @@ -398,21 +395,22 @@ async def test_internal_schema_kinds_only_when_in_diff( schema_branch = registry.schema.register_schema( schema=SchemaRoot(**internal_schema), branch=default_branch.name ) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff constraint_info_set.add(node_uniqueness_constraint("TestPerson", node_uuids=[CHANGED_PERSON_UUID])) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) # internal schema kinds are absent from the diff, so none contribute constraints assert set(constraints) == constraint_info_set internal_kinds = {"SchemaNode", "SchemaGeneric", "SchemaAttribute", "SchemaRelationship"} - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() constraints = await determiner.get_constraints( + schema_branch=schema_branch, node_diffs=[ node_diff, *(NodeDiffFieldSummary(kind=kind, attribute_node_uuids={"name": set()}) for kind in internal_kinds), - ] + ], ) # once an internal schema kind is in the diff, its uniqueness constraint must be validated # (this is what catches duplicate schema elements when a branch is merged) @@ -426,7 +424,7 @@ async def test_hierarchy_constraints_selected_for_both_endpoint_kinds( default_branch: Branch, ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # A hierarchy edge change surfaces on both endpoints: the child (LocationRack) records its # `parent` change and the parent (LocationSite) records its `children` change. Each endpoint # emits only the hierarchy constraint whose relationship actually changed there, and no @@ -442,7 +440,7 @@ async def test_hierarchy_constraints_selected_for_both_endpoint_kinds( *(relationship_constraint("LocationSite", "children", p) for p in RELATIONSHIP_PROPERTIES), } - constraints = await determiner.get_constraints(node_diffs=node_diffs) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=node_diffs) assert set(constraints) == expected @@ -455,13 +453,13 @@ async def test_unparseable_uniqueness_constraint_element_is_skipped_and_logged( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) person_schema = schema_branch.get(name="TestPerson", duplicate=False) person_schema.uniqueness_constraints = [["does_not_exist__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # `height` is not a unique attribute, so evaluating uniqueness must fall through to parsing # the (unparseable) constraint group rather than short-circuiting on a unique attribute. node_diff = NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"height": set()}) with caplog.at_level(logging.WARNING): - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert "Cannot parse TestPerson.uniqueness_constraints element 'does_not_exist__value'" in caplog.text # the unparseable element is skipped in isolation, so no uniqueness check is emitted for the kind @@ -473,12 +471,12 @@ async def test_hierarchy_constraint_scoped_to_changed_relationship( default_branch: Branch, ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # Re-parenting a rack changes only its `parent` relationship, so only the parent hierarchy # constraint may be emitted for that kind, never the children one. node_diffs = [NodeDiffFieldSummary(kind="LocationSite", relationship_node_uuids={"parent": set()})] - constraints = await determiner.get_constraints(node_diffs=node_diffs) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=node_diffs) constraint_set = set(constraints) assert node_constraint("LocationSite", "parent") in constraint_set diff --git a/backend/tests/component/core/constraint_validators/test_uniqueness_checker_node_scoped.py b/backend/tests/component/core/constraint_validators/test_uniqueness_checker_node_scoped.py index 709d8b156f8..3f4c0aa86e9 100644 --- a/backend/tests/component/core/constraint_validators/test_uniqueness_checker_node_scoped.py +++ b/backend/tests/component/core/constraint_validators/test_uniqueness_checker_node_scoped.py @@ -155,12 +155,12 @@ async def test_cross_kind_peer_change_resolves_and_detects_end_to_end( registry.schema.register_schema(schema=SchemaRoot(nodes=[car_schema]), branch=branch.name) synced_schema = registry.schema.get_node_schema(name="TestCar", branch=branch, duplicate=False) - determiner = build_constraint_validator_determiner( - db=db, branch=branch, schema_branch=registry.schema.get_schema_branch(name=branch.name) - ) + determiner = build_constraint_validator_determiner(db=db, branch=branch) person_change = NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"height": {person_john_main.id}}) - constraints = await determiner.get_constraints(node_diffs=[person_change]) + constraints = await determiner.get_constraints( + schema_branch=registry.schema.get_schema_branch(name=branch.name), node_diffs=[person_change] + ) car_constraint = next( c diff --git a/backend/tests/component/core/constraint_validators/test_uniqueness_scope.py b/backend/tests/component/core/constraint_validators/test_uniqueness_scope.py index f575554d0bb..c10f2c3728e 100644 --- a/backend/tests/component/core/constraint_validators/test_uniqueness_scope.py +++ b/backend/tests/component/core/constraint_validators/test_uniqueness_scope.py @@ -26,12 +26,10 @@ async def resolve( return set(self.dependents) -def _scoper(schema_branch: SchemaBranch, resolver: _RecordingResolver, node_diffs: list[NodeDiffFieldSummary]): +def _scoper(resolver: _RecordingResolver, node_diffs: list[NodeDiffFieldSummary]): node_diff_index = NodeDiffIndex() node_diff_index.initialize(node_diffs) - return UniquenessConstraintScoper( - schema_branch=schema_branch, dependent_resolver=resolver, node_diff_index=node_diff_index - ) + return UniquenessConstraintScoper(dependent_resolver=resolver, node_diff_index=node_diff_index) class TestUniquenessConstraintScoper: @@ -40,14 +38,16 @@ async def test_same_kind_change_scopes_to_changed_nodes( ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) scoper = _scoper( - schema_branch, _RecordingResolver(dependents=set()), [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"name": {"person-1", "person-2"}})], ) person_schema = schema_branch.get(name="TestPerson") - assert scoper.requires_validation(schema=person_schema) is True - assert await scoper.affected_node_uuids(schema=person_schema) == ["person-1", "person-2"] + assert scoper.requires_validation(schema_branch=schema_branch, schema=person_schema) is True + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=person_schema) == [ + "person-1", + "person-2", + ] async def test_scopes_only_nodes_that_changed_the_unique_field( self, car_person_schema: SchemaBranch, default_branch: Branch @@ -55,7 +55,6 @@ async def test_scopes_only_nodes_that_changed_the_unique_field( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) # person-1 changed the unique "name"; person-2 only changed the non-unique "height" scoper = _scoper( - schema_branch, _RecordingResolver(dependents=set()), [ NodeDiffFieldSummary( @@ -66,23 +65,22 @@ async def test_scopes_only_nodes_that_changed_the_unique_field( ) person_schema = schema_branch.get(name="TestPerson") - assert scoper.requires_validation(schema=person_schema) is True + assert scoper.requires_validation(schema_branch=schema_branch, schema=person_schema) is True # only person-1 is scoped; person-2's change does not participate in uniqueness - assert await scoper.affected_node_uuids(schema=person_schema) == ["person-1"] + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=person_schema) == ["person-1"] async def test_triggered_without_node_uuids_falls_back_to_full_scan( self, car_person_schema: SchemaBranch, default_branch: Branch ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) scoper = _scoper( - schema_branch, _RecordingResolver(dependents=set()), [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"name": set()})], ) person_schema = schema_branch.get(name="TestPerson") - assert scoper.requires_validation(schema=person_schema) is True - assert await scoper.affected_node_uuids(schema=person_schema) is None + assert scoper.requires_validation(schema_branch=schema_branch, schema=person_schema) is True + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=person_schema) is None async def test_unrelated_field_change_does_not_trigger( self, car_person_schema: SchemaBranch, default_branch: Branch @@ -90,14 +88,13 @@ async def test_unrelated_field_change_does_not_trigger( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) # height is not part of any TestPerson uniqueness constraint scoper = _scoper( - schema_branch, _RecordingResolver(dependents=set()), [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"height": {"person-1"}})], ) person_schema = schema_branch.get(name="TestPerson") - assert scoper.requires_validation(schema=person_schema) is False - assert await scoper.affected_node_uuids(schema=person_schema) is None + assert scoper.requires_validation(schema_branch=schema_branch, schema=person_schema) is False + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=person_schema) is None async def test_cross_kind_peer_change_resolves_dependents( self, car_person_schema: SchemaBranch, default_branch: Branch @@ -111,14 +108,13 @@ async def test_cross_kind_peer_change_resolves_dependents( resolver = _RecordingResolver(dependents={"car-1", "car-2"}) # a change to the peer kind's name, with no change to TestCar itself scoper = _scoper( - schema_branch, resolver, [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"name": {"person-1"}})], ) car_schema = schema_branch.get(name="TestCar") - assert scoper.requires_validation(schema=car_schema) is True - assert await scoper.affected_node_uuids(schema=car_schema) == ["car-1", "car-2"] + assert scoper.requires_validation(schema_branch=schema_branch, schema=car_schema) is True + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=car_schema) == ["car-1", "car-2"] # the peer change is routed to the resolver as a single call carrying the changed peer uuids owner_relationship = car_schema.get_relationship(name="owner") assert resolver.calls == [ @@ -137,12 +133,11 @@ async def test_cross_kind_without_known_peer_uuids_falls_back_to_full_scan( resolver = _RecordingResolver(dependents={"car-1"}) # the peer changed but its node uuids are unknown, so the dependents cannot be resolved scoper = _scoper( - schema_branch, resolver, [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"name": set()})], ) car_schema = schema_branch.get(name="TestCar") - assert scoper.requires_validation(schema=car_schema) is True - assert await scoper.affected_node_uuids(schema=car_schema) is None + assert scoper.requires_validation(schema_branch=schema_branch, schema=car_schema) is True + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=car_schema) is None assert not resolver.calls # resolver is never called when the peer uuids are unknown diff --git a/backend/tests/component/core/diff/repository/test_diff_field_summaries.py b/backend/tests/component/core/diff/repository/test_diff_field_summaries.py index 411c9f13a17..03c6c7f497e 100644 --- a/backend/tests/component/core/diff/repository/test_diff_field_summaries.py +++ b/backend/tests/component/core/diff/repository/test_diff_field_summaries.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Generator import pytest @@ -11,10 +12,12 @@ NodeDiffFieldSummary, ) from infrahub.core.diff.parent_node_adder import DiffParentNodeAdder +from infrahub.core.diff.query.field_summary import EnrichedDiffNodeFieldSummaryQuery from infrahub.core.diff.repository.deserializer import EnrichedDiffDeserializer from infrahub.core.diff.repository.repository import DiffRepository from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase +from tests.helpers.db_query_counter import CountingInfrahubDatabase from tests.helpers.diff_factories import ( EnrichedAttributeFactory, EnrichedNodeFactory, @@ -25,6 +28,24 @@ from .base import DiffRepositoryTestBase +@dataclass +class PageSizeCase: + name: str + query_size_limit: int + expected_query_count: int + + +NUM_CHANGED_NODES = 10 + +# One query per page, where the last page is the first to come back shorter than the page size. A +# node count that is an exact multiple of the page size therefore needs one extra, empty page. +PAGE_SIZE_CASES = [ + PageSizeCase(name="partial_last_page", query_size_limit=4, expected_query_count=3), + PageSizeCase(name="node_count_exact_multiple_of_page", query_size_limit=5, expected_query_count=3), + PageSizeCase(name="single_page", query_size_limit=50, expected_query_count=1), +] + + class TestDiffNodeFieldSummaries(DiffRepositoryTestBase): base_branch_name: str = "main" diff_branch_name: str = "diff" @@ -32,18 +53,33 @@ class TestDiffNodeFieldSummaries(DiffRepositoryTestBase): diff_to_time = Timestamp("2024-06-15T18:49:40Z") @pytest.fixture - def diff_repository(self, db: InfrahubDatabase) -> Generator[DiffRepository, None, None]: + def database_settings(self) -> Generator[None, None, None]: original_depth = config.SETTINGS.database.max_depth_search_hierarchy original_size = config.SETTINGS.database.query_size_limit config.SETTINGS.database.max_depth_search_hierarchy = 10 config.SETTINGS.database.query_size_limit = 50 - diff_repository = DiffRepository( - db=db, deserializer=EnrichedDiffDeserializer(DiffParentNodeAdder()), max_save_batch_size=30 - ) - yield diff_repository + yield config.SETTINGS.database.max_depth_search_hierarchy = original_depth config.SETTINGS.database.query_size_limit = original_size + @pytest.fixture + def diff_repository(self, db: InfrahubDatabase, database_settings: None) -> DiffRepository: + return DiffRepository( + db=db, deserializer=EnrichedDiffDeserializer(DiffParentNodeAdder()), max_save_batch_size=30 + ) + + @pytest.fixture + def counting_db(self, db: InfrahubDatabase) -> CountingInfrahubDatabase: + return CountingInfrahubDatabase.from_db(db=db) + + @pytest.fixture + def counting_diff_repository( + self, counting_db: CountingInfrahubDatabase, database_settings: None + ) -> DiffRepository: + return DiffRepository( + db=counting_db, deserializer=EnrichedDiffDeserializer(DiffParentNodeAdder()), max_save_batch_size=30 + ) + def _build_named_field_node( self, kind: str, @@ -176,3 +212,104 @@ async def test_get_node_field_summaries_excludes_other_diffs( diff_branch_name=requested_branch_name, diff_id=requested_diff.uuid ) assert retrieved_by_diff_id == expected_summaries + + async def test_get_node_field_summaries_empty_diff( + self, + counting_diff_repository: DiffRepository, + counting_db: CountingInfrahubDatabase, + reset_database: None, + ) -> None: + """A diff root with no diff nodes at all yields no summaries, and costs a single query. + + Such a root still produces one node-less row, which occupies a page slot without being a node. + Counting that row as a consumed node would keep pagination going past the only page, so the + page size here is one: it is the only size at which that miscount changes the query count. + """ + tracking_id = BranchTrackingId(name=self.diff_branch_name) + enriched_diff = EnrichedRootFactory.build( + base_branch_name=self.base_branch_name, + diff_branch_name=self.diff_branch_name, + from_time=self.diff_from_time, + to_time=self.diff_to_time, + nodes=set(), + tracking_id=tracking_id, + ) + await self._save_single_diff( + diff_repository=counting_diff_repository, enriched_diff=enriched_diff, do_summary_counts=False + ) + + config.SETTINGS.database.query_size_limit = 1 + counting_db.reset_counts() + retrieved = await counting_diff_repository.get_node_field_summaries( + diff_branch_name=self.diff_branch_name, tracking_id=tracking_id + ) + assert retrieved == [] + assert counting_db.count_for(EnrichedDiffNodeFieldSummaryQuery.name) == 1 + + @pytest.mark.parametrize("case", PAGE_SIZE_CASES, ids=lambda c: c.name) + async def test_get_node_field_summaries_batched( + self, + counting_diff_repository: DiffRepository, + counting_db: CountingInfrahubDatabase, + reset_database: None, + case: PageSizeCase, + ) -> None: + """Per-kind summaries are complete, and cost one query per page, when nodes span pages. + + Ten changed nodes of two kinds are saved, so each page size below ten forces every kind + across a page boundary; one node has only unchanged fields, so it fills a page slot without + contributing a summary. + + The query count pins the retrieval cost. Improperly configured Query subclasses can cause + duplicate queries to run. + """ + tracking_id = BranchTrackingId(name=self.diff_branch_name) + kinds = ["TestingKindAlpha", "TestingKindBravo"] + nodes: set[EnrichedDiffNode] = set() + expected_by_kind: dict[str, NodeDiffFieldSummary] = {} + for index in range(NUM_CHANGED_NODES - 1): + kind = kinds[index % len(kinds)] + node = self._build_named_field_node( + kind=kind, + node_action=DiffAction.UPDATED, + attribute_actions={ + "shared_attr": DiffAction.UPDATED, + f"attr_{index}": DiffAction.ADDED, + "quiet_attr": DiffAction.UNCHANGED, + }, + relationship_actions={"shared_rel": DiffAction.UPDATED}, + ) + nodes.add(node) + expected = expected_by_kind.setdefault(kind, NodeDiffFieldSummary(kind=kind)) + expected.add_attribute_node_uuid(name="shared_attr", node_uuid=node.uuid) + expected.add_attribute_node_uuid(name=f"attr_{index}", node_uuid=node.uuid) + expected.add_relationship_node_uuid(name="shared_rel", node_uuid=node.uuid) + nodes.add( + self._build_named_field_node( + kind=kinds[0], + node_action=DiffAction.UPDATED, + attribute_actions={"quiet_attr": DiffAction.UNCHANGED}, + relationship_actions={"quiet_rel": DiffAction.UNCHANGED}, + ) + ) + enriched_diff = EnrichedRootFactory.build( + base_branch_name=self.base_branch_name, + diff_branch_name=self.diff_branch_name, + from_time=self.diff_from_time, + to_time=self.diff_to_time, + nodes=nodes, + tracking_id=tracking_id, + ) + await self._save_single_diff( + diff_repository=counting_diff_repository, enriched_diff=enriched_diff, do_summary_counts=False + ) + + config.SETTINGS.database.query_size_limit = case.query_size_limit + counting_db.reset_counts() + retrieved = await counting_diff_repository.get_node_field_summaries( + diff_branch_name=self.diff_branch_name, tracking_id=tracking_id + ) + + assert len(retrieved) == len(expected_by_kind) + assert {summary.kind: summary for summary in retrieved} == expected_by_kind + assert counting_db.count_for(EnrichedDiffNodeFieldSummaryQuery.name) == case.expected_query_count diff --git a/backend/tests/component/core/diff/test_coordinator_lock.py b/backend/tests/component/core/diff/test_coordinator_lock.py index 81c54e5fa08..c7e875f3b15 100644 --- a/backend/tests/component/core/diff/test_coordinator_lock.py +++ b/backend/tests/component/core/diff/test_coordinator_lock.py @@ -20,6 +20,8 @@ from infrahub.core.rollback import GraphRollbacker from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.core.timestamp import Timestamp +from infrahub.core.validators.constraint_merge import build_constraint_info_merger +from infrahub.core.validators.determiner import build_constraint_validator_determiner from infrahub.core.validators.tasks import schema_validate_migrations from infrahub.database import InfrahubDatabase, get_db from infrahub.dependencies.registry import get_component_registry @@ -209,9 +211,10 @@ async def test_diff_update_blocks_merge( schema_manager=registry.schema, ), constraint_validator=MergeConstraintValidator( - db=db, branch=diff_branch, diff_repository=diff_repository, + determiner=build_constraint_validator_determiner(db=db, branch=diff_branch), + constraint_info_merger=build_constraint_info_merger(), migration_validator=schema_validate_migrations, ), ) @@ -267,9 +270,10 @@ async def test_merge_blocks_diff_update( schema_manager=registry.schema, ), constraint_validator=MergeConstraintValidator( - db=db2, branch=diff_branch, diff_repository=diff_repository_2, + determiner=build_constraint_validator_determiner(db=db2, branch=diff_branch), + constraint_info_merger=build_constraint_info_merger(), migration_validator=schema_validate_migrations, ), ) diff --git a/backend/tests/component/core/node/test_create_node_computed_pool.py b/backend/tests/component/core/node/test_create_node_computed_pool.py new file mode 100644 index 00000000000..0fa66bcc9cd --- /dev/null +++ b/backend/tests/component/core/node/test_create_node_computed_pool.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from infrahub.core import registry +from infrahub.core.node import Node +from infrahub.core.node.create import create_node +from tests.helpers.number_pool import register_and_provision_number_pools, snow_schema_with_format_identifier +from tests.helpers.schema_builders import computed_jinja2_attr + +if TYPE_CHECKING: + from infrahub.core.branch import Branch + from infrahub.database import InfrahubDatabase + + +async def test_create_with_jinja2_format_filter_on_number_pool( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """A Jinja2 macro that formats a pool-sourced attribute renders once the pool is allocated.""" + await register_and_provision_number_pools(db=db, branch=default_branch, schema=snow_schema_with_format_identifier()) + + incident_schema = registry.schema.get_node_schema(name="SnowIncident", branch=default_branch) + incident = await create_node( + data={"title": "The first issue"}, + db=db, + branch=default_branch, + schema=incident_schema, + ) + + assert incident.number.value == 1 + assert incident.identifier.value == "INC000000001" + + +async def test_new_with_unallocated_pool_renders_independent_macros( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """With pools unallocated, a pool-dependent macro is skipped while other macros still render.""" + schema = snow_schema_with_format_identifier( + extra_incident_attrs=[computed_jinja2_attr(name="slug", template="{{ title__value | lower }}", unique=False)] + ) + await register_and_provision_number_pools(db=db, branch=default_branch, schema=schema) + + incident_schema = registry.schema.get_node_schema(name="SnowIncident", branch=default_branch) + node = await Node.init(db=db, schema=incident_schema, branch=default_branch) + await node.new(db=db, process_pools=False, title="First Issue") + + assert node.number.value is None + assert node.slug.value == "first issue" + + +async def test_create_with_jinja2_macro_mixing_pool_and_plain_attributes( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """A macro referencing both a pool value and a plain attribute renders every variable after allocation.""" + schema = snow_schema_with_format_identifier( + identifier_template="INC{{ '%09d' | format(number__value) }}-{{ title__value | lower }}" + ) + await register_and_provision_number_pools(db=db, branch=default_branch, schema=schema) + + incident_schema = registry.schema.get_node_schema(name="SnowIncident", branch=default_branch) + incident = await create_node( + data={"title": "First Issue"}, + db=db, + branch=default_branch, + schema=incident_schema, + ) + + assert incident.number.value == 1 + assert incident.identifier.value == "INC000000001-first issue" + + +async def test_create_with_chained_macro_depending_on_pool_macro( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """A computed attribute chained on a pool-dependent one renders once the pool is allocated.""" + schema = snow_schema_with_format_identifier( + extra_incident_attrs=[ + computed_jinja2_attr(name="reference", template="REF-{{ identifier__value }}", unique=False) + ] + ) + await register_and_provision_number_pools(db=db, branch=default_branch, schema=schema) + + incident_schema = registry.schema.get_node_schema(name="SnowIncident", branch=default_branch) + incident = await create_node( + data={"title": "First Issue"}, + db=db, + branch=default_branch, + schema=incident_schema, + ) + + assert incident.number.value == 1 + assert incident.identifier.value == "INC000000001" + assert incident.reference.value == "REF-INC000000001" diff --git a/backend/tests/component/core/test_manager_node.py b/backend/tests/component/core/test_manager_node.py index 578c6a9c564..4381f552696 100644 --- a/backend/tests/component/core/test_manager_node.py +++ b/backend/tests/component/core/test_manager_node.py @@ -14,7 +14,7 @@ from infrahub.core.query.node import NodeToProcess from infrahub.core.registry import registry from infrahub.core.relationship import Relationship -from infrahub.core.schema import NodeSchema, SchemaRoot +from infrahub.core.schema import AttributeSchema, NodeSchema, SchemaRoot from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase @@ -374,6 +374,30 @@ async def test_iphost_attribute_value_is_normalized_after_save(db: InfrahubDatab assert reloaded.address.value == "192.0.2.10/32" +async def test_ipaddress_attribute_value_is_normalized_after_save(db: InfrahubDatabase, default_branch: Branch) -> None: + """An IPAddress attribute exposes its bare normalized value after a save/reload cycle.""" + schema_root = SchemaRoot( + nodes=[ + NodeSchema( + name="DnsRecord", + namespace="Test", + attributes=[AttributeSchema(name="address", kind="IPAddress")], + ) + ] + ) + registry.schema.register_schema(schema=schema_root, branch=default_branch.name) + + node = await Node.init(db=db, schema="TestDnsRecord", branch=default_branch) + await node.new(db=db, address="2001:0DB8::0001") + await node.save(db=db) + + assert node.get_attribute("address").value == "2001:db8::1" + + reloaded = await NodeManager.get_one(db=db, id=node.id, branch=default_branch) + assert reloaded is not None + assert reloaded.get_attribute("address").value == "2001:db8::1" + + async def test_macaddress_attribute_value_is_normalized_after_save( db: InfrahubDatabase, default_branch: Branch ) -> None: diff --git a/backend/tests/component/core/test_node_query.py b/backend/tests/component/core/test_node_query.py index 72c6e33d85c..dd2e9378f6f 100644 --- a/backend/tests/component/core/test_node_query.py +++ b/backend/tests/component/core/test_node_query.py @@ -77,6 +77,28 @@ async def test_query_NodeCreateAllQuery_iphost( assert await count_nodes(db=db, label="AttributeIPNetwork") == 0 +async def test_query_NodeCreateAllQuery_ipaddress( + db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema +) -> None: + """A bare IPAddress value shares the AttributeIPHost vertex shape, without any prefix in its value.""" + obj = await Node.init(db=db, schema="TestAllAttributeTypes", branch=default_branch) + await obj.new(db=db, bare_address="10.2.5.2") + + query = await NodeCreateAllQuery.init(db=db, node=obj, user_id="abcd") + await query.execute(db=db) + + nodes = await get_nodes(db=db, label="AttributeIPHost") + assert len(nodes) == 1 + attribute = nodes[0] + + assert attribute["value"] == "10.2.5.2" + assert attribute["version"] == 4 + assert attribute["binary_address"] == "00001010000000100000010100000010" + assert attribute["prefixlen"] == 32 + + assert await count_nodes(db=db, label="AttributeIPNetwork") == 0 + + async def test_query_NodeCreateAllQuery_ipnetwork( db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema ) -> None: diff --git a/backend/tests/component/git/conftest.py b/backend/tests/component/git/conftest.py index f8bd462af8b..2e2c7ad1bb6 100644 --- a/backend/tests/component/git/conftest.py +++ b/backend/tests/component/git/conftest.py @@ -2,6 +2,7 @@ import shutil from pathlib import Path from typing import Any, Generator +from unittest.mock import AsyncMock, patch import anyio import pytest @@ -33,6 +34,13 @@ def client() -> InfrahubClient: return InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) +@pytest.fixture +def mock_branch_all() -> Generator[AsyncMock]: + """Git sync queries all branches to skip merged/read-only ones; stub the SDK call with no such branches.""" + with patch("infrahub_sdk.branch.InfrahubBranchManager.all", new_callable=AsyncMock, return_value={}) as mock: + yield mock + + @pytest.fixture def git_upstream_repo_02(git_upstream_repo_01: dict[str, str | Path]) -> dict[str, str | Path]: """Delete all the branches but the main branch from git_upstream_repo_01""" diff --git a/backend/tests/component/git/test_git_repository.py b/backend/tests/component/git/test_git_repository.py index a590df5087e..9489feebba4 100644 --- a/backend/tests/component/git/test_git_repository.py +++ b/backend/tests/component/git/test_git_repository.py @@ -560,6 +560,7 @@ async def test_sync_new_branch( git_repo_03: InfrahubRepository, httpx_mock: HTTPXMock, mock_add_branch01_query: HTTPXMock, + mock_branch_all: AsyncMock, ) -> None: repo = git_repo_03 @@ -598,7 +599,9 @@ async def test_sync_new_branch( assert len(worktrees) == 4 -async def test_sync_updated_branch(prefect_test_fixture: None, git_repo_04: InfrahubRepository) -> None: +async def test_sync_updated_branch( + prefect_test_fixture: None, git_repo_04: InfrahubRepository, mock_branch_all: AsyncMock +) -> None: repo = git_repo_04 branch = Branch(name="branch01", uuid=uuid4()) @@ -621,7 +624,7 @@ async def test_sync_updated_branch(prefect_test_fixture: None, git_repo_04: Infr async def test_sync_continues_after_branch_pull_failure( - prefect_test_fixture: None, git_repo_07: InfrahubRepository + prefect_test_fixture: None, git_repo_07: InfrahubRepository, mock_branch_all: AsyncMock ) -> None: """A branch whose pull fails must not prevent the synchronization of the remaining branches.""" repo = git_repo_07 diff --git a/backend/tests/component/git/test_sync_lock_scope.py b/backend/tests/component/git/test_sync_lock_scope.py index e4fd1e7f314..9627099f587 100644 --- a/backend/tests/component/git/test_sync_lock_scope.py +++ b/backend/tests/component/git/test_sync_lock_scope.py @@ -1,3 +1,4 @@ +from unittest.mock import AsyncMock from uuid import uuid4 from infrahub.core.branch import Branch @@ -8,7 +9,7 @@ async def test_repository_lock_scopes_import_build_and_apply( - prefect_test_fixture: None, git_repo_04: InfrahubRepository + prefect_test_fixture: None, git_repo_04: InfrahubRepository, mock_branch_all: AsyncMock ) -> None: """The build phase of an import must run outside the lock and the apply phase inside it. diff --git a/backend/tests/component/graphql/test_graphql_query.py b/backend/tests/component/graphql/test_graphql_query.py index 7f916575405..0e1afbb0783 100644 --- a/backend/tests/component/graphql/test_graphql_query.py +++ b/backend/tests/component/graphql/test_graphql_query.py @@ -12,7 +12,7 @@ from infrahub.core.migrations.shared import MigrationInput from infrahub.core.node import Node from infrahub.core.path import SchemaPath -from infrahub.core.schema import NodeSchema, SchemaRoot +from infrahub.core.schema import NodeSchema from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase @@ -170,59 +170,6 @@ async def test_display_hfid(db: InfrahubDatabase, default_branch: Branch, animal } -async def test_display_hfid_related_node( - db: InfrahubDatabase, default_branch: Branch, animal_person_schema: SchemaBranch -) -> None: - person_schema = animal_person_schema.get_node(name="TestPerson") - dog_schema = animal_person_schema.get_node(name="TestDog") - - person1 = await Node.init(db=db, schema=person_schema, branch=default_branch) - await person1.new(db=db, name="Jack") - await person1.save(db=db) - - dog1 = await Node.init(db=db, schema=dog_schema, branch=default_branch) - await dog1.new(db=db, name="Rocky", breed="Labrador", owner=person1) - await dog1.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - hfid - animals { - edges { - node { - hfid - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestPerson"]["edges"]) == 1 - assert result.data["TestPerson"]["edges"][0] == { - "node": { - "animals": {"edges": [{"node": {"hfid": ["Jack", "Rocky"]}}]}, - "hfid": ["Jack"], - }, - } - - async def test_all_attributes( db: InfrahubDatabase, default_branch: Branch, data_schema: None, all_attribute_types_schema: NodeSchema ) -> None: @@ -237,6 +184,7 @@ async def test_all_attributes( myjson={"key1": "bill"}, ipaddress="10.5.0.1/27", prefix="10.1.0.0/22", + bare_address="10.5.0.1", ) await obj1.save(db=db) @@ -265,6 +213,11 @@ async def test_all_attributes( prefixlen netmask } + bare_address { + __typename + value + version + } } } } @@ -297,6 +250,10 @@ async def test_all_attributes( assert results["obj1"]["prefix"]["value"] == obj1.prefix.value assert results["obj1"]["prefix"]["netmask"] == obj1.prefix.netmask assert results["obj1"]["prefix"]["prefixlen"] == obj1.prefix.prefixlen + # a bare address round-trips without gaining a prefix, unlike the IPHost attribute above + assert results["obj1"]["bare_address"]["__typename"] == "IPAddress" + assert results["obj1"]["bare_address"]["value"] == "10.5.0.1" + assert results["obj1"]["bare_address"]["version"] == 4 assert results["obj2"]["mystring"]["value"] == obj2.mystring.value assert results["obj2"]["mybool"]["value"] == obj2.mybool.value @@ -309,75 +266,46 @@ async def test_all_attributes( assert results["obj2"]["prefix"]["value"] == obj2.prefix.value assert results["obj2"]["prefix"]["netmask"] is None assert results["obj2"]["prefix"]["prefixlen"] is None + assert results["obj2"]["bare_address"]["value"] == obj2.bare_address.value + assert results["obj2"]["bare_address"]["version"] is None -async def test_nested_query(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: - car = registry.schema.get_node_schema(name="TestCar") - person = registry.schema.get_node_schema(name="TestPerson") - - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) - - c1 = await Node.init(db=db, schema=car) - await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) - await c2.save(db=db) - c3 = await Node.init(db=db, schema=car) - await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) - await c3.save(db=db) +async def test_ipaddress_attribute_filters( + db: InfrahubDatabase, default_branch: Branch, data_schema: None, all_attribute_types_schema: NodeSchema +) -> None: + """An IPAddress attribute exposes the same filters as IPHost, matching on the normalized value.""" + obj1 = await Node.init(db=db, schema="TestAllAttributeTypes") + await obj1.new(db=db, name="obj1", bare_address="2001:0DB8::0001") + await obj1.save(db=db) - query = """ - query { - TestPerson { - edges { - node { - name { - value - } - cars { - edges { - node { - name { - value - } - } - } - } - } - } - } - } - """ + obj2 = await Node.init(db=db, schema="TestAllAttributeTypes") + await obj2.new(db=db, name="obj2", bare_address="10.0.0.2") + await obj2.save(db=db) default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - assert result.errors is None + async def names_for(filters: str) -> list[str]: + result = await graphql( + schema=gql_params.schema, + source="query { TestAllAttributeTypes(%s) { edges { node { name { value } } } } }" % filters, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + assert result.data + return sorted(item["node"]["name"]["value"] for item in result.data["TestAllAttributeTypes"]["edges"]) - assert result.data - result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} - assert sorted(result_per_name.keys()) == ["Jane", "John"] - assert len(result_per_name["John"]["cars"]["edges"]) == 2 - assert len(result_per_name["Jane"]["cars"]["edges"]) == 1 - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, c3.id} + # the compressed form matches, the expanded input form does not + assert await names_for('bare_address__value: "2001:db8::1"') == ["obj1"] + assert await names_for('bare_address__value: "2001:0DB8::0001"') == [] + assert await names_for('bare_address__values: ["2001:db8::1", "10.0.0.2"]') == ["obj1", "obj2"] + assert await names_for("bare_address__isnull: true") == [] + assert await names_for("bare_address__is_protected: false") == ["obj1", "obj2"] -async def test_double_nested_query( - db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch -) -> None: +async def test_query_typename(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: car = registry.schema.get_node_schema(name="TestCar") person = registry.schema.get_node_schema(name="TestPerson") @@ -401,22 +329,35 @@ async def test_double_nested_query( query = """ query { TestPerson { + __typename edges { + __typename node { + __typename name { value + __typename } cars { - count + __typename edges { + __typename + properties { + __typename + } node { + __typename name { + __typename value } owner { + __typename node { + __typename name { value + __typename } } } @@ -438,75 +379,36 @@ async def test_double_nested_query( variable_values={}, ) + assert result.data assert result.errors is None - assert result.data result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} assert sorted(result_per_name.keys()) == ["Jane", "John"] - assert len(result_per_name["John"]["cars"]["edges"]) == 2 - assert len(result_per_name["Jane"]["cars"]["edges"]) == 1 - assert result_per_name["John"]["cars"]["count"] == 2 - assert result_per_name["Jane"]["cars"]["count"] == 1 - assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["node"]["name"]["value"] == "John" - - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, c3.id} - - -async def test_nested_query_single_relationship( - db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, data_schema: None -) -> None: - raw_schema = { - "version": "1.0", - "generics": [ - { - "name": "Generic", - "namespace": "Location", - "hierarchical": True, - "attributes": [{"name": "name", "optional": False, "kind": "Text"}], - "relationships": [{"name": "devices", "peer": "InfraDevice", "cardinality": "many", "optional": True}], - } - ], - "nodes": [ - { - "name": "Device", - "namespace": "Infra", - "attributes": [{"name": "name", "kind": "Text", "optional": False}], - "relationships": [ - {"name": "location", "peer": "LocationGeneric", "optional": False, "cardinality": "one"} - ], - }, - { - "name": "Site", - "namespace": "Location", - "inherit_from": ["LocationGeneric"], - "attributes": [{"name": "description", "optional": False, "kind": "Text"}], - }, - ], - } - schema = SchemaRoot(**raw_schema) - schema_branch = registry.schema.register_schema(schema=schema, branch=default_branch.name) - - site_schema = schema_branch.get_node(name="LocationSite") - device_schema = schema_branch.get_node(name="InfraDevice") - - site1 = await Node.init(db=db, schema=site_schema, branch=default_branch) - await site1.new(db=db, name="site1", description="test") - await site1.save(db=db) + assert result.data["TestPerson"]["__typename"] == "PaginatedTestPerson" + assert result.data["TestPerson"]["edges"][0]["__typename"] == "EdgedTestPerson" + assert result.data["TestPerson"]["edges"][0]["node"]["__typename"] == "TestPerson" + assert result.data["TestPerson"]["edges"][0]["node"]["name"]["__typename"] == "TextAttribute" + assert result_per_name["John"]["cars"]["edges"][0]["node"]["__typename"] == "TestCar" + assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["__typename"] == "NestedEdgedTestPerson" + assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["node"]["name"]["__typename"] == "TextAttribute" + assert result_per_name["John"]["cars"]["edges"][0]["properties"]["__typename"] == "RelationshipProperty" - device1 = await Node.init(db=db, schema=device_schema, branch=default_branch) - await device1.new(db=db, name="device1", location=site1) - await device1.save(db=db) - device2 = await Node.init(db=db, schema=device_schema, branch=default_branch) - await device2.new(db=db, name="device2", location=site1) - await device2.save(db=db) +async def test_query_filter_ids(db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema) -> None: + obj1 = await Node.init(db=db, schema=criticality_schema) + await obj1.new(db=db, name="low", level=4) + await obj1.save(db=db) + obj2 = await Node.init(db=db, schema=criticality_schema) + await obj2.new(db=db, name="medium", level=3, description="My desc", color="#333333") + await obj2.save(db=db) + obj3 = await Node.init(db=db, schema=criticality_schema) + await obj3.new(db=db, name="high", level=1, description="My desc", color="#222222") + await obj3.save(db=db) - query = """ - fragment LocationData on LocationSite { - name { - value - } - devices { + query = ( + """ + query { + TestCriticality(ids: ["%s"]) { edges { node { name { @@ -516,24 +418,39 @@ async def test_nested_query_single_relationship( } } } + """ + % obj1.id + ) + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestCriticality"]["edges"]) == 1 + query = """ query { - InfraDevice { + TestCriticality(ids: ["%s", "%s"]) { edges { node { name { value } - location { - node { - ... LocationData - } - } } } } } - """ + """ % ( + obj1.id, + obj2.id, + ) default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( @@ -546,96 +463,26 @@ async def test_nested_query_single_relationship( assert result.errors is None assert result.data - result_per_name = { - result["node"]["name"]["value"]: result["node"] for result in result.data["InfraDevice"]["edges"] - } - assert sorted(result_per_name.keys()) == ["device1", "device2"] - expected_location_data = { - "node": { - "name": {"value": "site1"}, - "devices": {"edges": [{"node": {"name": {"value": "device1"}}}, {"node": {"name": {"value": "device2"}}}]}, - } - } - assert result.data["InfraDevice"]["edges"][0]["node"]["location"] == expected_location_data - assert result.data["InfraDevice"]["edges"][1]["node"]["location"] == expected_location_data + assert len(result.data["TestCriticality"]["edges"]) == 2 -async def test_nested_generic_query_many_relationship( - db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, data_schema: None +async def test_query_filter_relationship_isnull( + db: InfrahubDatabase, + default_branch: Branch, + person_albert_main: Node, + person_john_main: Node, + person_jane_main: Node, + car_camry_main: Node, + car_accord_main: Node, ) -> None: - """Validates that nested GraphQL fragments work for cardinality=many relationships.""" - raw_schema = { - "version": "1.0", - "generics": [ - { - "name": "Generic", - "namespace": "Location", - "hierarchical": True, - "attributes": [{"name": "name", "optional": False, "kind": "Text"}], - "relationships": [{"name": "devices", "peer": "InfraDevice", "cardinality": "many", "optional": True}], - } - ], - "nodes": [ - { - "name": "Device", - "namespace": "Infra", - "attributes": [{"name": "name", "kind": "Text", "optional": False}], - "relationships": [ - {"name": "location", "peer": "LocationGeneric", "optional": False, "cardinality": "one"} - ], - }, - { - "name": "Site", - "namespace": "Location", - "inherit_from": ["LocationGeneric"], - "attributes": [{"name": "description", "optional": False, "kind": "Text"}], - }, - ], - } - schema = SchemaRoot(**raw_schema) - schema_branch = registry.schema.register_schema(schema=schema, branch=default_branch.name) - - site_schema = schema_branch.get_node(name="LocationSite") - device_schema = schema_branch.get_node(name="InfraDevice") - - site1 = await Node.init(db=db, schema=site_schema, branch=default_branch) - await site1.new(db=db, name="site1", description="test") - await site1.save(db=db) - - device1 = await Node.init(db=db, schema=device_schema, branch=default_branch) - await device1.new(db=db, name="device1", location=site1) - await device1.save(db=db) - - device2 = await Node.init(db=db, schema=device_schema, branch=default_branch) - await device2.new(db=db, name="device2", location=site1) - await device2.save(db=db) - query = """ - fragment DeviceData on InfraDevice { - name { - value - } - } - - fragment LocationData on LocationSite { - name { - value - } - devices { - edges { - node { - ...DeviceData - } - } - } - } - query { - LocationSite { + TestPerson(cars__isnull: true) { + count edges { - node { - ...LocationData - } + node { + id + } } } } @@ -651,250 +498,37 @@ async def test_nested_generic_query_many_relationship( ) assert result.errors is None + assert result.data + assert result.data["TestPerson"]["count"] == 1 + assert len(result.data["TestPerson"]["edges"]) == 1 + assert result.data["TestPerson"]["edges"][0]["node"]["id"] == person_albert_main.id - assert result.data == { - "LocationSite": { - "edges": [ - { - "node": { - "name": {"value": "site1"}, - "devices": { - "edges": [ - {"node": {"name": {"value": "device1"}}}, - {"node": {"name": {"value": "device2"}}}, - ] - }, - } + query = """ + query { + TestPerson(cars__isnull: false) { + count + edges { + node { + id } - ] + } } } + """ + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) - -async def test_query_typename(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: - car = registry.schema.get_node_schema(name="TestCar") - person = registry.schema.get_node_schema(name="TestPerson") - - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) - - c1 = await Node.init(db=db, schema=car) - await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) - await c2.save(db=db) - c3 = await Node.init(db=db, schema=car) - await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) - await c3.save(db=db) - - query = """ - query { - TestPerson { - __typename - edges { - __typename - node { - __typename - name { - value - __typename - } - cars { - __typename - edges { - __typename - properties { - __typename - } - node { - __typename - name { - __typename - value - } - owner { - __typename - node { - __typename - name { - value - __typename - } - } - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.data - assert result.errors is None - - result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} - assert sorted(result_per_name.keys()) == ["Jane", "John"] - assert result.data["TestPerson"]["__typename"] == "PaginatedTestPerson" - assert result.data["TestPerson"]["edges"][0]["__typename"] == "EdgedTestPerson" - assert result.data["TestPerson"]["edges"][0]["node"]["__typename"] == "TestPerson" - assert result.data["TestPerson"]["edges"][0]["node"]["name"]["__typename"] == "TextAttribute" - assert result_per_name["John"]["cars"]["edges"][0]["node"]["__typename"] == "TestCar" - assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["__typename"] == "NestedEdgedTestPerson" - assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["node"]["name"]["__typename"] == "TextAttribute" - assert result_per_name["John"]["cars"]["edges"][0]["properties"]["__typename"] == "RelationshipProperty" - - -async def test_query_filter_ids(db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema) -> None: - obj1 = await Node.init(db=db, schema=criticality_schema) - await obj1.new(db=db, name="low", level=4) - await obj1.save(db=db) - obj2 = await Node.init(db=db, schema=criticality_schema) - await obj2.new(db=db, name="medium", level=3, description="My desc", color="#333333") - await obj2.save(db=db) - obj3 = await Node.init(db=db, schema=criticality_schema) - await obj3.new(db=db, name="high", level=1, description="My desc", color="#222222") - await obj3.save(db=db) - - query = ( - """ - query { - TestCriticality(ids: ["%s"]) { - edges { - node { - name { - value - } - } - } - } - } - """ - % obj1.id - ) - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestCriticality"]["edges"]) == 1 - - query = """ - query { - TestCriticality(ids: ["%s", "%s"]) { - edges { - node { - name { - value - } - } - } - } - } - """ % ( - obj1.id, - obj2.id, - ) - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestCriticality"]["edges"]) == 2 - - -async def test_query_filter_relationship_isnull( - db: InfrahubDatabase, - default_branch: Branch, - person_albert_main: Node, - person_john_main: Node, - person_jane_main: Node, - car_camry_main: Node, - car_accord_main: Node, -) -> None: - query = """ - query { - TestPerson(cars__isnull: true) { - count - edges { - node { - id - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert result.data["TestPerson"]["count"] == 1 - assert len(result.data["TestPerson"]["edges"]) == 1 - assert result.data["TestPerson"]["edges"][0]["node"]["id"] == person_albert_main.id - - query = """ - query { - TestPerson(cars__isnull: false) { - count - edges { - node { - id - } - } - } - } - """ - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert result.data["TestPerson"]["count"] == 2 - assert len(result.data["TestPerson"]["edges"]) == 2 - result_person_ids = {node["node"]["id"] for node in result.data["TestPerson"]["edges"]} - assert result_person_ids == {person_john_main.id, person_jane_main.id} + assert result.errors is None + assert result.data + assert result.data["TestPerson"]["count"] == 2 + assert len(result.data["TestPerson"]["edges"]) == 2 + result_person_ids = {node["node"]["id"] for node in result.data["TestPerson"]["edges"]} + assert result_person_ids == {person_john_main.id, person_jane_main.id} async def test_query_filter_attribute_isnull( @@ -1618,134 +1252,22 @@ async def test_query_attribute_multiple_values( assert result.data["TestPerson"]["count"] == 2 -async def test_query_relationship_multiple_values( - db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch -) -> None: - car = registry.schema.get(name="TestCar") - person = registry.schema.get(name="TestPerson") - - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) +async def test_query_at_specific_time(db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None) -> None: + t1 = await Node.init(db=db, schema="TestingTag") + await t1.new(db=db, name="Blue", description="The Blue tag") + await t1.save(db=db) + t2 = await Node.init(db=db, schema="TestingTag") + await t2.new(db=db, name="Red") + await t2.save(db=db) - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) + time1 = Timestamp() - c1 = await Node.init(db=db, schema=car) - await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) - await c2.save(db=db) - c3 = await Node.init(db=db, schema=car) - await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) - await c3.save(db=db) - c4 = await Node.init(db=db, schema=car) - await c4.new(db=db, name="yaris", nbr_seats=5, is_electric=False, owner=p1) - await c4.save(db=db) + t2.name.value = "Green" + await t2.save(db=db) query = """ query { - TestPerson { - edges { - node { - name { - value - } - cars (name__values: ["volt", "nolt"]) { - edges { - node { - name { - value - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestPerson"]["edges"]) == 2 - assert result.data["TestPerson"]["edges"][0]["node"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" - assert result.data["TestPerson"]["edges"][1]["node"]["cars"]["edges"][0]["node"]["name"]["value"] == "nolt" - - -async def test_query_oneway_relationship(db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None) -> None: - t1 = await Node.init(db=db, schema=InfrahubKind.TAG) - await t1.new(db=db, name="Blue", description="The Blue tag") - await t1.save(db=db) - t2 = await Node.init(db=db, schema=InfrahubKind.TAG) - await t2.new(db=db, name="Red") - await t2.save(db=db) - p1 = await Node.init(db=db, schema="TestPerson") - await p1.new(db=db, firstname="John", lastname="Doe", tags=[t1, t2]) - await p1.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - id - tags { - edges { - node { - name { - value - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"]) == 2 - - -async def test_query_at_specific_time(db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None) -> None: - t1 = await Node.init(db=db, schema="TestingTag") - await t1.new(db=db, name="Blue", description="The Blue tag") - await t1.save(db=db) - t2 = await Node.init(db=db, schema="TestingTag") - await t2.new(db=db, name="Red") - await t2.save(db=db) - - time1 = Timestamp() - - t2.name.value = "Green" - await t2.save(db=db) - - query = """ - query { - TestingTag { + TestingTag { edges { node { name { @@ -1934,85 +1456,6 @@ async def test_query_node_updated_at(db: InfrahubDatabase, default_branch: Branc # TODO IFC-1813 add test for cardinality-one updated_at -async def test_query_relationship_updated_at( - db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None -) -> None: - t1 = await Node.init(db=db, schema=InfrahubKind.TAG) - await t1.new(db=db, name="Blue", description="The Blue tag") - await t1.save(db=db) - t2 = await Node.init(db=db, schema=InfrahubKind.TAG) - await t2.new(db=db, name="Red") - await t2.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - id - tags { - edges { - node_metadata { - updated_at - } - node { - name { - value - } - } - properties { - updated_at - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result1 = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result1.errors is None - assert result1.data - assert result1.data["TestPerson"]["edges"] == [] - - p1 = await Node.init(db=db, schema="TestPerson") - await p1.new(db=db, firstname="John", lastname="Doe", tags=[t1, t2]) - await p1.save(db=db) - - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result2 = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result2.errors is None - assert result2.data - assert len(result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"]) == 2 - assert result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] is not None - assert ( - result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] - != result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["properties"]["updated_at"] - ) - assert result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"][ - "updated_at" - ] == Timestamp( - result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] - ).to_string(with_z=False) - - async def test_query_attribute_node_property_source( db: InfrahubDatabase, default_branch: Branch, @@ -2082,418 +1525,90 @@ async def test_query_attribute_node_property_owner( # test node-level query query = """ - query { - TestPerson { - edges { - node { - id - name { - value - owner { - id - display_label - } - is_from_profile - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result1 = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result1.errors is None - assert result1.data - assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"] - assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"]["id"] == first_account.id - assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"][ - "display_label" - ] == await first_account.get_display_label(db=db) - assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["is_from_profile"] is False - assert gql_params.context.related_node_ids == {p1.id, first_account.id} - - # test relationship-level query - query = """ - query { - TestCar { - edges { - node { - id - owner { - node { - id - name { - value - owner { - id - display_label - } - is_from_profile - } - } - } - } - } - } - - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result2 = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result2.errors is None - - assert result2.data - assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"] - assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"]["id"] == first_account.id - assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"][ - "display_label" - ] == await first_account.get_display_label(db=db) - assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["is_from_profile"] is False - assert gql_params.context.related_node_ids == {c1.id, p1.id, first_account.id} - - -async def test_query_relationship_node_property( - db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch, first_account: Node -) -> None: - car = registry.schema.get(name="TestCar") - person = registry.schema.get(name="TestPerson") - - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) - - c1 = await Node.init(db=db, schema=car) - await c1.new( - db=db, - name="volt", - nbr_seats=4, - is_electric=True, - owner={"id": p1, "_relation__owner": first_account.id}, - ) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new( - db=db, - name="bolt", - nbr_seats=4, - is_electric=True, - owner={"id": p2, "_relation__source": first_account.id}, - ) - await c2.save(db=db) - - # test many relationship query - query = """ - query { - TestPerson { - edges { - node { - id - name { - value - } - cars { - edges { - node { - name { - value - } - } - properties { - owner { - id - } - source { - id - } - } - } - } - } - } - } - } - """ - - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - assert result.errors is None - assert result.data - results = {item["node"]["name"]["value"]: item["node"] for item in result.data["TestPerson"]["edges"]} - assert sorted(results.keys()) == ["Jane", "John"] - assert len(results["John"]["cars"]["edges"]) == 1 - assert len(results["Jane"]["cars"]["edges"]) == 1 - - assert results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" - assert results["John"]["cars"]["edges"][0]["properties"]["owner"] - assert results["John"]["cars"]["edges"][0]["properties"]["owner"]["id"] == first_account.id - assert results["John"]["cars"]["edges"][0]["properties"]["source"] is None - - assert results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" - assert results["Jane"]["cars"]["edges"][0]["properties"]["owner"] is None - assert results["Jane"]["cars"]["edges"][0]["properties"]["source"] - assert results["Jane"]["cars"]["edges"][0]["properties"]["source"]["id"] == first_account.id - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} - - # test single relationship query - query = """ - query { - TestCar { - edges { - node { - id - name { - value - } - owner { - node { - name { - value - } - } - properties { - owner { - id - } - source { - id - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - assert result.errors is None - - assert result.data - results = {item["node"]["name"]["value"]: item["node"] for item in result.data["TestCar"]["edges"]} - assert set(results.keys()) == {"volt", "bolt"} - - assert results["volt"]["owner"]["node"]["name"]["value"] == "John" - assert results["volt"]["owner"]["properties"]["owner"] - assert results["volt"]["owner"]["properties"]["owner"]["id"] == first_account.id - assert results["volt"]["owner"]["properties"]["source"] is None - - assert results["bolt"]["owner"]["node"]["name"]["value"] == "Jane" - assert results["bolt"]["owner"]["properties"]["owner"] is None - assert results["bolt"]["owner"]["properties"]["source"] - assert results["bolt"]["owner"]["properties"]["source"]["id"] == first_account.id - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} - - # test many relationship query with mixed properties on peer - query = """ - query { - people_with_cars_and_owners: TestPerson { - edges { - node { - id - name { - value - } - cars { - edges { - node { - name { - value - } - } - properties { - owner { - id - } - } - } - } - } - } - } - people_with_cars_and_sources: TestPerson { - edges { - node { - id - name { - value - } - cars { - edges { - node { - name { - value - } - } - properties { - source { - id - } - } + query { + TestPerson { + edges { + node { + id + name { + value + owner { + id + display_label } + is_from_profile } } } } } """ - default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( + result1 = await graphql( schema=gql_params.schema, source=query, context_value=gql_params.context, root_value=None, variable_values={}, ) - assert result.errors is None - assert result.data - - owner_results = { - item["node"]["name"]["value"]: item["node"] for item in result.data["people_with_cars_and_owners"]["edges"] - } - assert sorted(owner_results.keys()) == ["Jane", "John"] - assert len(owner_results["John"]["cars"]["edges"]) == 1 - assert len(owner_results["Jane"]["cars"]["edges"]) == 1 - - assert owner_results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" - assert owner_results["John"]["cars"]["edges"][0]["properties"]["owner"] - assert owner_results["John"]["cars"]["edges"][0]["properties"]["owner"]["id"] == first_account.id - assert "source" not in owner_results["John"]["cars"]["edges"][0]["properties"] - - assert owner_results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" - assert owner_results["Jane"]["cars"]["edges"][0]["properties"]["owner"] is None - assert "source" not in owner_results["Jane"]["cars"]["edges"][0]["properties"] - - source_results = { - item["node"]["name"]["value"]: item["node"] for item in result.data["people_with_cars_and_sources"]["edges"] - } - assert sorted(source_results.keys()) == ["Jane", "John"] - assert len(source_results["John"]["cars"]["edges"]) == 1 - assert len(source_results["Jane"]["cars"]["edges"]) == 1 - - assert source_results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" - assert "owner" not in source_results["John"]["cars"]["edges"][0]["properties"] - assert source_results["John"]["cars"]["edges"][0]["properties"]["source"] is None - - assert source_results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" - assert "owner" not in source_results["Jane"]["cars"]["edges"][0]["properties"] - assert source_results["Jane"]["cars"]["edges"][0]["properties"]["source"] - assert source_results["Jane"]["cars"]["edges"][0]["properties"]["source"]["id"] == first_account.id - - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} + assert result1.errors is None + assert result1.data + assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"] + assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"]["id"] == first_account.id + assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"][ + "display_label" + ] == await first_account.get_display_label(db=db) + assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["is_from_profile"] is False + assert gql_params.context.related_node_ids == {p1.id, first_account.id} -async def test_same_many_relationship_with_different_limits_offsets( - db: InfrahubDatabase, - default_branch: Branch, - person_john_main: Node, - person_jane_main: Node, - car_accord_main: Node, - car_prius_main: Node, - car_camry_main: Node, - car_yaris_main: Node, -) -> None: + # test relationship-level query query = """ query { - people_with_cars_1: TestPerson { - edges { - node { - id - name { - value - } - cars(limit: 1, offset: 0) { - edges { - node { - id - } - } - } - } - } - } - people_with_cars_2: TestPerson { + TestCar { edges { node { id - name { - value - } - cars(limit: 1, offset: 1) { - edges { - node { - id + owner { + node { + id + name { + value + owner { + id + display_label + } + is_from_profile } } } } } } + } """ - john_cars_by_uuid = sorted([car_accord_main, car_prius_main], key=lambda c: c.id) - jane_cars_by_uuid = sorted([car_camry_main, car_yaris_main], key=lambda c: c.id) - default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( + result2 = await graphql( schema=gql_params.schema, source=query, context_value=gql_params.context, root_value=None, variable_values={}, ) - assert result.errors is None - assert result.data - for person_node in result.data["people_with_cars_1"]["edges"]: - person_name = person_node["node"]["name"]["value"] - assert len(person_node["node"]["cars"]["edges"]) == 1 - if person_name == "John": - assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == john_cars_by_uuid[0].id - elif person_name == "Jane": - assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == jane_cars_by_uuid[0].id - for person_node in result.data["people_with_cars_2"]["edges"]: - person_name = person_node["node"]["name"]["value"] - assert len(person_node["node"]["cars"]["edges"]) == 1 - if person_name == "John": - assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == john_cars_by_uuid[1].id - elif person_name == "Jane": - assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == jane_cars_by_uuid[1].id + assert result2.errors is None + + assert result2.data + assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"] + assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"]["id"] == first_account.id + assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"][ + "display_label" + ] == await first_account.get_display_label(db=db) + assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["is_from_profile"] is False + assert gql_params.context.related_node_ids == {c1.id, p1.id, first_account.id} async def test_query_attribute_flag_property( @@ -2707,129 +1822,6 @@ async def test_model_node_interface(db: InfrahubDatabase, default_branch: Branch assert gql_params.context.related_node_ids == {d1.id, d2.id} -async def test_model_rel_interface(db: InfrahubDatabase, default_branch: Branch, vehicule_person_schema: None) -> None: - d1 = await Node.init(db=db, schema="TestCar") - await d1.new(db=db, name="Porsche 911", nbr_doors=2) - await d1.save(db=db) - - b1 = await Node.init(db=db, schema="TestBoat") - await b1.new(db=db, name="Laser", has_sails=True) - await b1.save(db=db) - - p1 = await Node.init(db=db, schema="TestPerson") - await p1.new(db=db, name="John Doe", vehicules=[d1, b1]) - await p1.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - name { - value - } - vehicules { - edges { - node { - name { - value - } - ... on TestCar { - nbr_doors { - value - } - } - ... on TestBoat { - has_sails { - value - } - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestPerson"]["edges"][0]["node"]["vehicules"]["edges"]) == 2 - expected_results = { - "name": {"value": "John Doe"}, - "vehicules": { - "edges": [ - {"node": {"name": {"value": "Porsche 911"}, "nbr_doors": {"value": 2}}}, - {"node": {"has_sails": {"value": True}, "name": {"value": "Laser"}}}, - ] - }, - } - assert DeepDiff(result.data["TestPerson"]["edges"][0]["node"], expected_results, ignore_order=True).to_dict() == {} - - -async def test_model_rel_interface_reverse( - db: InfrahubDatabase, default_branch: Branch, vehicule_person_schema: None -) -> None: - d1 = await Node.init(db=db, schema="TestCar") - await d1.new(db=db, name="Porsche 911", nbr_doors=2) - await d1.save(db=db) - - b1 = await Node.init(db=db, schema="TestBoat") - await b1.new(db=db, name="Laser", has_sails=True) - await b1.save(db=db) - - p1 = await Node.init(db=db, schema="TestPerson") - await p1.new(db=db, name="John Doe", vehicules=[d1, b1]) - await p1.save(db=db) - - query = """ - query { - TestBoat { - edges { - node { - name { - value - } - owners { - edges { - node { - name { - value - } - } - } - - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestBoat"]["edges"][0]["node"]["owners"]["edges"]) == 1 - - async def test_generic_root_with_pagination( db: InfrahubDatabase, default_branch: Branch, car_person_generics_data: dict[str, Node] ) -> None: @@ -3310,133 +2302,6 @@ async def test_hierarchical_location_include_descendants( assert asia["things"]["count"] == 7 -async def test_properties_on_different_query_paths( - db: InfrahubDatabase, - default_branch: Branch, - hierarchical_location_data_thing: dict[str, Node], - account_bob: Node, - account_bill: Node, -) -> None: - paris_owner = account_bob - paris_rack_ids = [node.id for name, node in hierarchical_location_data_thing.items() if name.startswith("paris-r")] - paris_racks = await NodeManager.get_many(db=db, ids=paris_rack_ids) - for rack in paris_racks.values(): - thing_rels = await rack.things.get_relationships(db=db) - await rack.things.update( - db=db, data=[{"id": rel.peer_id, "_relation__owner": paris_owner.id} for rel in thing_rels] - ) - await rack.save(db=db) - - london_source = account_bill - london_rack_ids = [ - node.id for name, node in hierarchical_location_data_thing.items() if name.startswith("london-r") - ] - london_racks = await NodeManager.get_many(db=db, ids=london_rack_ids) - for rack in london_racks.values(): - thing_rels = await rack.things.get_relationships(db=db) - await rack.things.update( - db=db, data=[{"id": rel.peer_id, "_relation__source": london_source.id} for rel in thing_rels] - ) - await rack.save(db=db) - - query = """ - query GetRack { - LocationRack(parent__name__values: "europe") { - edges { - node { - id - name { - value - } - things { - edges { - properties { - owner { - id - } - } - node { - id - name { - value - } - } - } - } - } - } - } - LocationSite(parent__name__values: "europe") { - edges { - node { - id - name { - value - } - children { - edges { - node { - name { - value - } - things { - edges { - properties { - source { - id - } - } - node { - id - name { - value - } - } - } - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - - # check owners are correct - for rack in result.data["LocationRack"]["edges"]: - rack_name = rack["node"]["name"]["value"] - for thing_rel in rack["node"]["things"]["edges"]: - assert "source" not in thing_rel["properties"] - if rack_name.startswith("paris"): - assert thing_rel["properties"]["owner"]["id"] == paris_owner.id - else: - assert thing_rel["properties"]["owner"] is None - - # check sources are correct - for site in result.data["LocationSite"]["edges"]: - for rack in site["node"]["children"]["edges"]: - rack_name = rack["node"]["name"]["value"] - for thing_rel in rack["node"]["things"]["edges"]: - assert "owner" not in thing_rel["properties"] - if rack_name.startswith("london"): - assert thing_rel["properties"]["source"]["id"] == london_source.id - else: - assert thing_rel["properties"]["source"] is None - - async def test_hierarchical_groups_descendants( db: InfrahubDatabase, default_branch: Branch, hierarchical_groups_data: dict[str, Node] ) -> None: diff --git a/backend/tests/component/graphql/test_graphql_read_rel_query.py b/backend/tests/component/graphql/test_graphql_read_rel_query.py new file mode 100644 index 00000000000..221aac70e8d --- /dev/null +++ b/backend/tests/component/graphql/test_graphql_read_rel_query.py @@ -0,0 +1,1303 @@ +from copy import deepcopy +from typing import Any + +import pytest +from deepdiff import DeepDiff + +from infrahub.core import registry +from infrahub.core.branch import Branch +from infrahub.core.constants import InfrahubKind +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node +from infrahub.core.schema import SchemaRoot +from infrahub.core.schema.schema_branch import SchemaBranch +from infrahub.core.timestamp import Timestamp +from infrahub.database import InfrahubDatabase +from infrahub.graphql.initialization import prepare_graphql_params +from tests.helpers.graphql import graphql + + +async def test_display_hfid_related_node( + db: InfrahubDatabase, default_branch: Branch, animal_person_schema: SchemaBranch +) -> None: + person_schema = animal_person_schema.get_node(name="TestPerson") + dog_schema = animal_person_schema.get_node(name="TestDog") + + person1 = await Node.init(db=db, schema=person_schema, branch=default_branch) + await person1.new(db=db, name="Jack") + await person1.save(db=db) + + dog1 = await Node.init(db=db, schema=dog_schema, branch=default_branch) + await dog1.new(db=db, name="Rocky", breed="Labrador", owner=person1) + await dog1.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + hfid + animals { + edges { + node { + hfid + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestPerson"]["edges"]) == 1 + assert result.data["TestPerson"]["edges"][0] == { + "node": { + "animals": {"edges": [{"node": {"hfid": ["Jack", "Rocky"]}}]}, + "hfid": ["Jack"], + }, + } + + +async def test_nested_query(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: + car = registry.schema.get_node_schema(name="TestCar") + person = registry.schema.get_node_schema(name="TestPerson") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) + await c2.save(db=db) + c3 = await Node.init(db=db, schema=car) + await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) + await c3.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + name { + value + } + cars { + edges { + node { + name { + value + } + } + } + } + } + } + } + } + """ + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + + assert result.data + result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} + assert sorted(result_per_name.keys()) == ["Jane", "John"] + assert len(result_per_name["John"]["cars"]["edges"]) == 2 + assert len(result_per_name["Jane"]["cars"]["edges"]) == 1 + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, c3.id} + + +async def test_double_nested_query( + db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch +) -> None: + car = registry.schema.get_node_schema(name="TestCar") + person = registry.schema.get_node_schema(name="TestPerson") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) + await c2.save(db=db) + c3 = await Node.init(db=db, schema=car) + await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) + await c3.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + name { + value + } + cars { + count + edges { + node { + name { + value + } + owner { + node { + name { + value + } + } + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + + assert result.data + result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} + assert sorted(result_per_name.keys()) == ["Jane", "John"] + assert len(result_per_name["John"]["cars"]["edges"]) == 2 + assert len(result_per_name["Jane"]["cars"]["edges"]) == 1 + assert result_per_name["John"]["cars"]["count"] == 2 + assert result_per_name["Jane"]["cars"]["count"] == 1 + assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["node"]["name"]["value"] == "John" + + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, c3.id} + + +async def test_nested_query_single_relationship( + db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, data_schema: None +) -> None: + raw_schema = { + "version": "1.0", + "generics": [ + { + "name": "Generic", + "namespace": "Location", + "hierarchical": True, + "attributes": [{"name": "name", "optional": False, "kind": "Text"}], + "relationships": [{"name": "devices", "peer": "InfraDevice", "cardinality": "many", "optional": True}], + } + ], + "nodes": [ + { + "name": "Device", + "namespace": "Infra", + "attributes": [{"name": "name", "kind": "Text", "optional": False}], + "relationships": [ + {"name": "location", "peer": "LocationGeneric", "optional": False, "cardinality": "one"} + ], + }, + { + "name": "Site", + "namespace": "Location", + "inherit_from": ["LocationGeneric"], + "attributes": [{"name": "description", "optional": False, "kind": "Text"}], + }, + ], + } + schema = SchemaRoot(**raw_schema) + schema_branch = registry.schema.register_schema(schema=schema, branch=default_branch.name) + + site_schema = schema_branch.get_node(name="LocationSite") + device_schema = schema_branch.get_node(name="InfraDevice") + + site1 = await Node.init(db=db, schema=site_schema, branch=default_branch) + await site1.new(db=db, name="site1", description="test") + await site1.save(db=db) + + device1 = await Node.init(db=db, schema=device_schema, branch=default_branch) + await device1.new(db=db, name="device1", location=site1) + await device1.save(db=db) + + device2 = await Node.init(db=db, schema=device_schema, branch=default_branch) + await device2.new(db=db, name="device2", location=site1) + await device2.save(db=db) + + query = """ + fragment LocationData on LocationSite { + name { + value + } + devices { + edges { + node { + name { + value + } + } + } + } + } + + query { + InfraDevice { + edges { + node { + name { + value + } + location { + node { + ... LocationData + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + result_per_name = { + result["node"]["name"]["value"]: result["node"] for result in result.data["InfraDevice"]["edges"] + } + assert sorted(result_per_name.keys()) == ["device1", "device2"] + expected_location_data = { + "node": { + "name": {"value": "site1"}, + "devices": {"edges": [{"node": {"name": {"value": "device1"}}}, {"node": {"name": {"value": "device2"}}}]}, + } + } + assert result.data["InfraDevice"]["edges"][0]["node"]["location"] == expected_location_data + assert result.data["InfraDevice"]["edges"][1]["node"]["location"] == expected_location_data + + +async def test_nested_generic_query_many_relationship( + db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, data_schema: None +) -> None: + """Validates that nested GraphQL fragments work for cardinality=many relationships.""" + raw_schema = { + "version": "1.0", + "generics": [ + { + "name": "Generic", + "namespace": "Location", + "hierarchical": True, + "attributes": [{"name": "name", "optional": False, "kind": "Text"}], + "relationships": [{"name": "devices", "peer": "InfraDevice", "cardinality": "many", "optional": True}], + } + ], + "nodes": [ + { + "name": "Device", + "namespace": "Infra", + "attributes": [{"name": "name", "kind": "Text", "optional": False}], + "relationships": [ + {"name": "location", "peer": "LocationGeneric", "optional": False, "cardinality": "one"} + ], + }, + { + "name": "Site", + "namespace": "Location", + "inherit_from": ["LocationGeneric"], + "attributes": [{"name": "description", "optional": False, "kind": "Text"}], + }, + ], + } + schema = SchemaRoot(**raw_schema) + schema_branch = registry.schema.register_schema(schema=schema, branch=default_branch.name) + + site_schema = schema_branch.get_node(name="LocationSite") + device_schema = schema_branch.get_node(name="InfraDevice") + + site1 = await Node.init(db=db, schema=site_schema, branch=default_branch) + await site1.new(db=db, name="site1", description="test") + await site1.save(db=db) + + device1 = await Node.init(db=db, schema=device_schema, branch=default_branch) + await device1.new(db=db, name="device1", location=site1) + await device1.save(db=db) + + device2 = await Node.init(db=db, schema=device_schema, branch=default_branch) + await device2.new(db=db, name="device2", location=site1) + await device2.save(db=db) + + query = """ + fragment DeviceData on InfraDevice { + name { + value + } + } + + fragment LocationData on LocationSite { + name { + value + } + devices { + edges { + node { + ...DeviceData + } + } + } + } + + query { + LocationSite { + edges { + node { + ...LocationData + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + + assert result.data == { + "LocationSite": { + "edges": [ + { + "node": { + "name": {"value": "site1"}, + "devices": { + "edges": [ + {"node": {"name": {"value": "device1"}}}, + {"node": {"name": {"value": "device2"}}}, + ] + }, + } + } + ] + } + } + + +async def test_query_relationship_multiple_values( + db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch +) -> None: + car = registry.schema.get(name="TestCar") + person = registry.schema.get(name="TestPerson") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) + await c2.save(db=db) + c3 = await Node.init(db=db, schema=car) + await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) + await c3.save(db=db) + c4 = await Node.init(db=db, schema=car) + await c4.new(db=db, name="yaris", nbr_seats=5, is_electric=False, owner=p1) + await c4.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + name { + value + } + cars (name__values: ["volt", "nolt"]) { + edges { + node { + name { + value + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestPerson"]["edges"]) == 2 + assert result.data["TestPerson"]["edges"][0]["node"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" + assert result.data["TestPerson"]["edges"][1]["node"]["cars"]["edges"][0]["node"]["name"]["value"] == "nolt" + + +async def test_query_oneway_relationship(db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None) -> None: + t1 = await Node.init(db=db, schema=InfrahubKind.TAG) + await t1.new(db=db, name="Blue", description="The Blue tag") + await t1.save(db=db) + t2 = await Node.init(db=db, schema=InfrahubKind.TAG) + await t2.new(db=db, name="Red") + await t2.save(db=db) + p1 = await Node.init(db=db, schema="TestPerson") + await p1.new(db=db, firstname="John", lastname="Doe", tags=[t1, t2]) + await p1.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + id + tags { + edges { + node { + name { + value + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"]) == 2 + + +async def test_query_relationship_updated_at( + db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None +) -> None: + t1 = await Node.init(db=db, schema=InfrahubKind.TAG) + await t1.new(db=db, name="Blue", description="The Blue tag") + await t1.save(db=db) + t2 = await Node.init(db=db, schema=InfrahubKind.TAG) + await t2.new(db=db, name="Red") + await t2.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + id + tags { + edges { + node_metadata { + updated_at + } + node { + name { + value + } + } + properties { + updated_at + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result1 = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result1.errors is None + assert result1.data + assert result1.data["TestPerson"]["edges"] == [] + + p1 = await Node.init(db=db, schema="TestPerson") + await p1.new(db=db, firstname="John", lastname="Doe", tags=[t1, t2]) + await p1.save(db=db) + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result2 = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result2.errors is None + assert result2.data + assert len(result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"]) == 2 + assert result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] is not None + assert ( + result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] + != result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["properties"]["updated_at"] + ) + assert result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"][ + "updated_at" + ] == Timestamp( + result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] + ).to_string(with_z=False) + + +async def test_query_relationship_node_property( + db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch, first_account: Node +) -> None: + car = registry.schema.get(name="TestCar") + person = registry.schema.get(name="TestPerson") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new( + db=db, + name="volt", + nbr_seats=4, + is_electric=True, + owner={"id": p1, "_relation__owner": first_account.id}, + ) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new( + db=db, + name="bolt", + nbr_seats=4, + is_electric=True, + owner={"id": p2, "_relation__source": first_account.id}, + ) + await c2.save(db=db) + + # test many relationship query + query = """ + query { + TestPerson { + edges { + node { + id + name { + value + } + cars { + edges { + node { + name { + value + } + } + properties { + owner { + id + } + source { + id + } + } + } + } + } + } + } + } + """ + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + assert result.data + results = {item["node"]["name"]["value"]: item["node"] for item in result.data["TestPerson"]["edges"]} + assert sorted(results.keys()) == ["Jane", "John"] + assert len(results["John"]["cars"]["edges"]) == 1 + assert len(results["Jane"]["cars"]["edges"]) == 1 + + assert results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" + assert results["John"]["cars"]["edges"][0]["properties"]["owner"] + assert results["John"]["cars"]["edges"][0]["properties"]["owner"]["id"] == first_account.id + assert results["John"]["cars"]["edges"][0]["properties"]["source"] is None + + assert results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" + assert results["Jane"]["cars"]["edges"][0]["properties"]["owner"] is None + assert results["Jane"]["cars"]["edges"][0]["properties"]["source"] + assert results["Jane"]["cars"]["edges"][0]["properties"]["source"]["id"] == first_account.id + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} + + # test single relationship query + query = """ + query { + TestCar { + edges { + node { + id + name { + value + } + owner { + node { + name { + value + } + } + properties { + owner { + id + } + source { + id + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + + assert result.data + results = {item["node"]["name"]["value"]: item["node"] for item in result.data["TestCar"]["edges"]} + assert set(results.keys()) == {"volt", "bolt"} + + assert results["volt"]["owner"]["node"]["name"]["value"] == "John" + assert results["volt"]["owner"]["properties"]["owner"] + assert results["volt"]["owner"]["properties"]["owner"]["id"] == first_account.id + assert results["volt"]["owner"]["properties"]["source"] is None + + assert results["bolt"]["owner"]["node"]["name"]["value"] == "Jane" + assert results["bolt"]["owner"]["properties"]["owner"] is None + assert results["bolt"]["owner"]["properties"]["source"] + assert results["bolt"]["owner"]["properties"]["source"]["id"] == first_account.id + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} + + # test many relationship query with mixed properties on peer + query = """ + query { + people_with_cars_and_owners: TestPerson { + edges { + node { + id + name { + value + } + cars { + edges { + node { + name { + value + } + } + properties { + owner { + id + } + } + } + } + } + } + } + people_with_cars_and_sources: TestPerson { + edges { + node { + id + name { + value + } + cars { + edges { + node { + name { + value + } + } + properties { + source { + id + } + } + } + } + } + } + } + } + """ + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + assert result.data + + owner_results = { + item["node"]["name"]["value"]: item["node"] for item in result.data["people_with_cars_and_owners"]["edges"] + } + assert sorted(owner_results.keys()) == ["Jane", "John"] + assert len(owner_results["John"]["cars"]["edges"]) == 1 + assert len(owner_results["Jane"]["cars"]["edges"]) == 1 + + assert owner_results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" + assert owner_results["John"]["cars"]["edges"][0]["properties"]["owner"] + assert owner_results["John"]["cars"]["edges"][0]["properties"]["owner"]["id"] == first_account.id + assert "source" not in owner_results["John"]["cars"]["edges"][0]["properties"] + + assert owner_results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" + assert owner_results["Jane"]["cars"]["edges"][0]["properties"]["owner"] is None + assert "source" not in owner_results["Jane"]["cars"]["edges"][0]["properties"] + + source_results = { + item["node"]["name"]["value"]: item["node"] for item in result.data["people_with_cars_and_sources"]["edges"] + } + assert sorted(source_results.keys()) == ["Jane", "John"] + assert len(source_results["John"]["cars"]["edges"]) == 1 + assert len(source_results["Jane"]["cars"]["edges"]) == 1 + + assert source_results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" + assert "owner" not in source_results["John"]["cars"]["edges"][0]["properties"] + assert source_results["John"]["cars"]["edges"][0]["properties"]["source"] is None + + assert source_results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" + assert "owner" not in source_results["Jane"]["cars"]["edges"][0]["properties"] + assert source_results["Jane"]["cars"]["edges"][0]["properties"]["source"] + assert source_results["Jane"]["cars"]["edges"][0]["properties"]["source"]["id"] == first_account.id + + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} + + +async def test_same_many_relationship_with_different_limits_offsets( + db: InfrahubDatabase, + default_branch: Branch, + person_john_main: Node, + person_jane_main: Node, + car_accord_main: Node, + car_prius_main: Node, + car_camry_main: Node, + car_yaris_main: Node, +) -> None: + query = """ + query { + people_with_cars_1: TestPerson { + edges { + node { + id + name { + value + } + cars(limit: 1, offset: 0) { + edges { + node { + id + } + } + } + } + } + } + people_with_cars_2: TestPerson { + edges { + node { + id + name { + value + } + cars(limit: 1, offset: 1) { + edges { + node { + id + } + } + } + } + } + } + } + """ + john_cars_by_uuid = sorted([car_accord_main, car_prius_main], key=lambda c: c.id) + jane_cars_by_uuid = sorted([car_camry_main, car_yaris_main], key=lambda c: c.id) + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + assert result.data + + for person_node in result.data["people_with_cars_1"]["edges"]: + person_name = person_node["node"]["name"]["value"] + assert len(person_node["node"]["cars"]["edges"]) == 1 + if person_name == "John": + assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == john_cars_by_uuid[0].id + elif person_name == "Jane": + assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == jane_cars_by_uuid[0].id + for person_node in result.data["people_with_cars_2"]["edges"]: + person_name = person_node["node"]["name"]["value"] + assert len(person_node["node"]["cars"]["edges"]) == 1 + if person_name == "John": + assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == john_cars_by_uuid[1].id + elif person_name == "Jane": + assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == jane_cars_by_uuid[1].id + + +async def test_model_rel_interface(db: InfrahubDatabase, default_branch: Branch, vehicule_person_schema: None) -> None: + d1 = await Node.init(db=db, schema="TestCar") + await d1.new(db=db, name="Porsche 911", nbr_doors=2) + await d1.save(db=db) + + b1 = await Node.init(db=db, schema="TestBoat") + await b1.new(db=db, name="Laser", has_sails=True) + await b1.save(db=db) + + p1 = await Node.init(db=db, schema="TestPerson") + await p1.new(db=db, name="John Doe", vehicules=[d1, b1]) + await p1.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + name { + value + } + vehicules { + edges { + node { + name { + value + } + ... on TestCar { + nbr_doors { + value + } + } + ... on TestBoat { + has_sails { + value + } + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestPerson"]["edges"][0]["node"]["vehicules"]["edges"]) == 2 + expected_results = { + "name": {"value": "John Doe"}, + "vehicules": { + "edges": [ + {"node": {"name": {"value": "Porsche 911"}, "nbr_doors": {"value": 2}}}, + {"node": {"has_sails": {"value": True}, "name": {"value": "Laser"}}}, + ] + }, + } + assert DeepDiff(result.data["TestPerson"]["edges"][0]["node"], expected_results, ignore_order=True).to_dict() == {} + + +async def test_model_rel_interface_reverse( + db: InfrahubDatabase, default_branch: Branch, vehicule_person_schema: None +) -> None: + d1 = await Node.init(db=db, schema="TestCar") + await d1.new(db=db, name="Porsche 911", nbr_doors=2) + await d1.save(db=db) + + b1 = await Node.init(db=db, schema="TestBoat") + await b1.new(db=db, name="Laser", has_sails=True) + await b1.save(db=db) + + p1 = await Node.init(db=db, schema="TestPerson") + await p1.new(db=db, name="John Doe", vehicules=[d1, b1]) + await p1.save(db=db) + + query = """ + query { + TestBoat { + edges { + node { + name { + value + } + owners { + edges { + node { + name { + value + } + } + } + + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestBoat"]["edges"][0]["node"]["owners"]["edges"]) == 1 + + +async def test_properties_on_different_query_paths( + db: InfrahubDatabase, + default_branch: Branch, + hierarchical_location_data_thing: dict[str, Node], + account_bob: Node, + account_bill: Node, +) -> None: + paris_owner = account_bob + paris_rack_ids = [node.id for name, node in hierarchical_location_data_thing.items() if name.startswith("paris-r")] + paris_racks = await NodeManager.get_many(db=db, ids=paris_rack_ids) + for rack in paris_racks.values(): + thing_rels = await rack.things.get_relationships(db=db) + await rack.things.update( + db=db, data=[{"id": rel.peer_id, "_relation__owner": paris_owner.id} for rel in thing_rels] + ) + await rack.save(db=db) + + london_source = account_bill + london_rack_ids = [ + node.id for name, node in hierarchical_location_data_thing.items() if name.startswith("london-r") + ] + london_racks = await NodeManager.get_many(db=db, ids=london_rack_ids) + for rack in london_racks.values(): + thing_rels = await rack.things.get_relationships(db=db) + await rack.things.update( + db=db, data=[{"id": rel.peer_id, "_relation__source": london_source.id} for rel in thing_rels] + ) + await rack.save(db=db) + + query = """ + query GetRack { + LocationRack(parent__name__values: "europe") { + edges { + node { + id + name { + value + } + things { + edges { + properties { + owner { + id + } + } + node { + id + name { + value + } + } + } + } + } + } + } + LocationSite(parent__name__values: "europe") { + edges { + node { + id + name { + value + } + children { + edges { + node { + name { + value + } + things { + edges { + properties { + source { + id + } + } + node { + id + name { + value + } + } + } + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + + # check owners are correct + for rack in result.data["LocationRack"]["edges"]: + rack_name = rack["node"]["name"]["value"] + for thing_rel in rack["node"]["things"]["edges"]: + assert "source" not in thing_rel["properties"] + if rack_name.startswith("paris"): + assert thing_rel["properties"]["owner"]["id"] == paris_owner.id + else: + assert thing_rel["properties"]["owner"] is None + + # check sources are correct + for site in result.data["LocationSite"]["edges"]: + for rack in site["node"]["children"]["edges"]: + rack_name = rack["node"]["name"]["value"] + for thing_rel in rack["node"]["things"]["edges"]: + assert "owner" not in thing_rel["properties"] + if rack_name.startswith("london"): + assert thing_rel["properties"]["source"]["id"] == london_source.id + else: + assert thing_rel["properties"]["source"] is None + + +async def test_single_relationship_id_only_uses_preloaded_peer_id( + db: InfrahubDatabase, + default_branch: Branch, + animal_person_schema_unregistered: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify cardinality-one peer ID relationship shortcut + + TestAnimal is a generic, so favorite_animal is a cardinality-one relationship whose + GraphQL node field is an interface: the id-only shortcut can only answer it without + hydrating the peer when the preloaded stub carries the peer's concrete kind. owner + covers the same shortcut when the peer field is a concrete node type. + """ + schema_dict = deepcopy(animal_person_schema_unregistered) + person_node = next(node for node in schema_dict["nodes"] if node["name"] == "Person") + person_node["relationships"].append( + { + "name": "favorite_animal", + "peer": "TestAnimal", + "optional": True, + "identifier": "person__favorite_animal", + "cardinality": "one", + "direction": "outbound", + } + ) + schema_branch = registry.schema.register_schema(schema=SchemaRoot(**schema_dict), branch=default_branch.name) + + person_schema = schema_branch.get_node(name="TestPerson", duplicate=False) + dog_schema = schema_branch.get_node(name="TestDog", duplicate=False) + + person = await Node.init(db=db, schema=person_schema, branch=default_branch) + await person.new(db=db, name="Jack") + await person.save(db=db) + + dog = await Node.init(db=db, schema=dog_schema, branch=default_branch) + await dog.new(db=db, name="Rocky", breed="Labrador", owner=person) + await dog.save(db=db) + + await person.get_relationship("favorite_animal").update(db=db, data=dog) + await person.save(db=db) + + async def fail_node_load(*_args: object, **_kwargs: object) -> None: + raise AssertionError("the ID-only relationship unexpectedly used NodeDataLoader") + + monkeypatch.setattr( + "infrahub.graphql.resolvers.single_relationship.NodeDataLoader.load", + fail_node_load, + ) + + default_branch.update_schema_hash() + + concrete_peer_query = """ + query { + TestDog { + edges { + node { + id + owner { node { id } } + } + } + } + } + """ + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=concrete_peer_query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data == {"TestDog": {"edges": [{"node": {"id": dog.id, "owner": {"node": {"id": person.id}}}}]}} + assert gql_params.context.related_node_ids == {dog.id, person.id} + + generic_peer_query = """ + query { + TestPerson { + edges { + node { + id + favorite_animal { node { id } } + } + } + } + } + """ + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=generic_peer_query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data == { + "TestPerson": {"edges": [{"node": {"id": person.id, "favorite_animal": {"node": {"id": dog.id}}}}]} + } + assert gql_params.context.related_node_ids == {person.id, dog.id} diff --git a/backend/tests/component/graphql/test_mutation_create_jinja2_attributes.py b/backend/tests/component/graphql/test_mutation_create_jinja2_attributes.py index 02e0e7239aa..68ab2193163 100644 --- a/backend/tests/component/graphql/test_mutation_create_jinja2_attributes.py +++ b/backend/tests/component/graphql/test_mutation_create_jinja2_attributes.py @@ -6,8 +6,10 @@ from infrahub.core.node import Node from infrahub.core.schema import SchemaRoot, internal_schema from infrahub.graphql.initialization import prepare_graphql_params +from infrahub.graphql.registry import registry as graphql_registry from tests.constants import TestKind from tests.helpers.graphql import graphql +from tests.helpers.number_pool import register_and_provision_number_pools, snow_schema_with_format_identifier from tests.helpers.schema import CHILD, LOCATION_SCHEMA, THING, load_schema if TYPE_CHECKING: @@ -219,3 +221,36 @@ async def test_create_with_jinja2_with_generics( assert site_result.data assert site_result.data["TestingSiteCreate"]["ok"] is True assert site_result.data["TestingSiteCreate"]["object"]["code"]["value"] == "st" + + +async def test_create_with_jinja2_format_filter_on_number_pool( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + await register_and_provision_number_pools(db=db, branch=default_branch, schema=snow_schema_with_format_identifier()) + default_branch.update_schema_hash() + graphql_registry.clear_cache() + + query = """ + mutation { + SnowIncidentCreate(data: { title: { value: "The first issue" } }) { + ok + object { + id + identifier { value } + } + } + } + """ + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert result.data["SnowIncidentCreate"]["ok"] is True + assert result.data["SnowIncidentCreate"]["object"]["identifier"]["value"] == "INC000000001" diff --git a/backend/tests/component/merge_recompute_coalescing/test_merge_submits_coalesced.py b/backend/tests/component/merge_recompute_coalescing/test_merge_submits_coalesced.py index 48d835d718b..3e799587a15 100644 --- a/backend/tests/component/merge_recompute_coalescing/test_merge_submits_coalesced.py +++ b/backend/tests/component/merge_recompute_coalescing/test_merge_submits_coalesced.py @@ -12,6 +12,9 @@ from infrahub.core.branch import Branch from infrahub.core.branch.tasks import merge_branch, rebase_branch from infrahub.core.diff.coordinator import DiffCoordinator +from infrahub.core.initialization import create_branch +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node from infrahub.dependencies.registry import get_component_registry from infrahub.workers.dependencies import build_cache, build_database, build_event_service, build_workflow from infrahub.workflows.catalogue import ( @@ -22,7 +25,14 @@ from tests.adapters.cache import MemoryCache from tests.adapters.event import MemoryInfrahubEvent from tests.adapters.workflow import WorkflowRecorder -from tests.helpers.merge_recompute.dataset import load_profile_schema, seed_branch +from tests.helpers.merge_recompute.dataset import ( + PROFILE_NODE_KIND, + PROFILE_PEER_KIND, + build_profile_schema, + load_profile_schema, + seed_branch, +) +from tests.helpers.schema import load_schema if TYPE_CHECKING: from fast_depends import Provider @@ -143,3 +153,64 @@ async def test_rebase_submits_one_coalesced_recompute_per_target( # A rebase recomputes on the user branch, not the destination. assert computed[0]["parameters"]["branch_name"] == seeded.branch_name assert display[0]["parameters"]["branch_name"] == seeded.branch_name + + +async def test_merge_delete_peer_coalesces_reader_recompute_by_own_id( + db: InfrahubDatabase, + default_branch: Branch, + register_core_models_schema: SchemaBranch, + dependency_provider: Provider, +) -> None: + lock.initialize_lock(local_only=True) + + # Optional peer so it can be deleted while the reader survives. + schema = build_profile_schema() + node_schema = next(node for node in schema.nodes if node.kind == PROFILE_NODE_KIND) + node_schema.relationships[0].optional = True + await load_schema(db=db, schema=schema, update_db=True) + + peer = await Node.init(db=db, schema=PROFILE_PEER_KIND, branch=default_branch) + await peer.new(db=db, name="beta") + await peer.save(db=db) + # Several readers of the same peer, to prove the deletion coalesces them rather than fanning out. + reader_ids: list[str] = [] + for index in range(3): + reader = await Node.init(db=db, schema=PROFILE_NODE_KIND, branch=default_branch) + await reader.new(db=db, name=f"reader-{index}", peer=peer) + await reader.save(db=db) + reader_ids.append(reader.id) + + branch = await create_branch(branch_name="delete-peer-submit", db=db) + peer_on_branch = await NodeManager.get_one(id=peer.id, db=db, branch=branch) + assert peer_on_branch is not None + await peer_on_branch.delete(db=db) + + component_registry = get_component_registry() + diff_coordinator = await component_registry.get_component(DiffCoordinator, db=db, branch=branch) + await diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=branch) + + recorder = WorkflowRecorder() + event_recorder = MemoryInfrahubEvent() + cache = MemoryCache() + context = InfrahubContext.init( + branch=default_branch, + account=AccountSession(account_id=str(uuid4()), auth_type=AuthType.NONE), + ) + with ( + dependency_provider.scope(build_database, lambda singleton=True: db), # noqa: ARG005 + dependency_provider.scope(build_event_service, lambda: event_recorder), + dependency_provider.scope(build_workflow, lambda: recorder), + dependency_provider.scope(build_cache, lambda: cache), + ): + await merge_branch(branch=branch.name, context=context) + + # The reverse lookup from the deleted peer finds no readers once its edges close, so the readers + # must be recomputed by their own ids, coalesced into one submission per family. + for workflow in (COMPUTED_ATTRIBUTE_PROCESS_JINJA2, DISPLAY_LABELS_PROCESS_JINJA2): + own_id_submissions = [ + call + for call in recorder.get_submit_calls_for(workflow) + if call["parameters"]["node_kind"] == PROFILE_NODE_KIND + ] + assert len(own_id_submissions) == 1, f"{workflow.name} fanned out instead of coalescing the readers" + assert sorted(own_id_submissions[0]["parameters"]["object_ids"]) == sorted(reader_ids) diff --git a/backend/tests/component/telemetry/test_database.py b/backend/tests/component/telemetry/test_database.py new file mode 100644 index 00000000000..a07367e0e09 --- /dev/null +++ b/backend/tests/component/telemetry/test_database.py @@ -0,0 +1,168 @@ +from infrahub.core import utils +from infrahub.core.branch import Branch +from infrahub.core.constants import InfrahubKind +from infrahub.core.initialization import create_branch +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node +from infrahub.core.schema import NodeSchema +from infrahub.core.schema.schema_branch import SchemaBranch +from infrahub.database import InfrahubDatabase +from infrahub.telemetry.database import ( + count_user_nodes, + gather_database_information, + get_server_info, + get_system_info, +) + + +async def test_get_server_info(db: InfrahubDatabase) -> None: + servers = await get_server_info(db) + assert len(servers) == 1 + + +async def test_get_system_info(db: InfrahubDatabase) -> None: + system_info = await get_system_info(db) + assert system_info is not None + + +async def test_gather_database_information(db: InfrahubDatabase) -> None: + data = await gather_database_information.fn(db) + assert data is not None + + +async def test_gather_database_information_corenode_matches_seeded( + db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch +) -> None: + """``corenode`` matches an independently-computed managed-node count exactly. + + The oracle is a raw ``CoreNode``-label count — a different code path from the gather's + ``NodeManager.count`` — and the raw ``total`` must stay untouched and never below the subset. + """ + # Baseline of pre-existing managed nodes via a raw label count — a different code path + # from NodeManager.count — so the assertion holds regardless of any nodes already present. + baseline_corenode = await utils.count_nodes(db=db, label=InfrahubKind.NODE) + + seeded = 5 + for index in range(seeded): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=f"person-{index}") + await person.save(db=db) + + expected_corenode = baseline_corenode + seeded + # Independent oracle: raw CoreNode-label vertex count after seeding (not NodeManager.count). + independent_corenode = await utils.count_nodes(db=db, label=InfrahubKind.NODE) + assert independent_corenode == expected_corenode + + data = await gather_database_information.fn(db) + + assert data.node_count["corenode"] == expected_corenode + # Raw vertex total stays as-is and always contains at least the managed-node subset. + assert data.node_count["total"] == await utils.count_nodes(db=db) + assert data.node_count["total"] >= data.node_count["corenode"] + + +async def test_gather_database_information_user_counts_only_user_namespaces( + db: InfrahubDatabase, + default_branch: Branch, + register_core_models_schema: SchemaBranch, + car_person_schema: SchemaBranch, +) -> None: + """``user`` counts only user-defined-namespace nodes, excluding Core. + + A seeded ``CoreAccount`` (restricted ``Core`` namespace) is a managed ``CoreNode`` but must + not be counted by ``user`` — forcing ``user < corenode`` and proving Core is excluded, while + ``user`` ⊆ ``corenode`` ⊆ ``total`` still holds. + """ + # With only the user-editable Test namespace registered and no user nodes yet, the gather + # reports zero user nodes — the independent baseline the seeded count is measured against. + baseline = await gather_database_information.fn(db) + assert baseline.node_count["user"] == 0 + + seeded_users = 4 + for index in range(seeded_users): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=f"user-person-{index}") + await person.save(db=db) + + # A single Core management node: a CoreNode that lives outside every user-editable namespace. + account = await Node.init(db=db, schema=InfrahubKind.ACCOUNT, branch=default_branch) + await account.new(db=db, name="core-account", account_type="User", password="accountPassword123") + await account.save(db=db) + + data = await gather_database_information.fn(db) + + # Exactly the seeded user nodes are counted; the Core account is not. + assert data.node_count["user"] == seeded_users + + # All three counts are populated in this scenario; None is only reported on a count fallback. + user_count = data.node_count["user"] + corenode_count = data.node_count["corenode"] + total_count = data.node_count["total"] + assert user_count is not None + assert corenode_count is not None + assert total_count is not None + + # Strict nesting: user ⊆ corenode ⊆ total. + assert user_count <= corenode_count <= total_count + # The Core account is a managed node excluded from user, so user is strictly below corenode. + assert user_count < corenode_count + + +async def test_count_user_nodes_is_branch_aware( + db: InfrahubDatabase, + default_branch: Branch, + register_core_models_schema: SchemaBranch, + car_person_schema: SchemaBranch, +) -> None: + """``user`` reflects the default branch at gather time: deleted and branch-only nodes are excluded.""" + assert await count_user_nodes(db=db) == 0 + + persons: list[Node] = [] + for index in range(3): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=f"branch-aware-person-{index}") + await person.save(db=db) + persons.append(person) + + # A node deleted on the default branch stops being counted. + await persons[0].delete(db=db) + + # A node that exists only on another branch is invisible to the default-branch count. + other_branch = await create_branch(branch_name="user-count-branch", db=db) + branch_person = await Node.init(db=db, schema="TestPerson", branch=other_branch) + await branch_person.new(db=db, name="branch-only-person") + await branch_person.save(db=db) + + assert await count_user_nodes(db=db) == 2 + + +async def test_count_user_nodes_matches_per_kind_oracle( + db: InfrahubDatabase, + default_branch: Branch, + register_core_models_schema: SchemaBranch, + car_person_schema: SchemaBranch, +) -> None: + """The single-pass count equals summing a per-kind branch-aware count over the same kinds.""" + seeded_persons = 3 + persons: list[Node] = [] + for index in range(seeded_persons): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=f"oracle-person-{index}", height=180) + await person.save(db=db) + persons.append(person) + + seeded_cars = 2 + for index in range(seeded_cars): + car = await Node.init(db=db, schema="TestCar", branch=default_branch) + await car.new(db=db, name=f"oracle-car-{index}", nbr_seats=4, is_electric=False, owner=persons[0]) + await car.save(db=db) + + schema_branch = db.schema.get_schema_branch(name=default_branch.name) + user_namespaces = [namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable] + oracle = 0 + for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces): + if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from: + oracle += await NodeManager.count(db=db, schema=node_schema.kind, branch=default_branch) + + assert oracle == seeded_persons + seeded_cars + assert await count_user_nodes(db=db) == oracle diff --git a/backend/tests/component/telemetry/test_datatabase.py b/backend/tests/component/telemetry/test_datatabase.py deleted file mode 100644 index bb5c7175116..00000000000 --- a/backend/tests/component/telemetry/test_datatabase.py +++ /dev/null @@ -1,17 +0,0 @@ -from infrahub.database import InfrahubDatabase -from infrahub.telemetry.database import gather_database_information, get_server_info, get_system_info - - -async def test_get_server_info(db: InfrahubDatabase) -> None: - servers = await get_server_info(db) - assert len(servers) == 1 - - -async def test_get_system_info(db: InfrahubDatabase) -> None: - system_info = await get_system_info(db) - assert system_info is not None - - -async def test_gather_database_information(db: InfrahubDatabase) -> None: - data = await gather_database_information.fn(db) - assert data is not None diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index 12f01b8142c..11e923da845 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -1,6 +1,295 @@ -from collections.abc import Generator +import asyncio +import uuid +from collections.abc import AsyncGenerator, Generator +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, cast -from infrahub.telemetry.task_manager import gather_prefect_information +import pytest +from prefect.client.orchestration import PrefectClient, get_client +from prefect.client.schemas.objects import State, StateType +from prefect.events.schemas.events import Event, Resource + +from infrahub.events.account_action import AccountLoggedInEvent +from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent +from infrahub.events.branch_action import BranchCreatedEvent, BranchDeletedEvent, BranchMergedEvent +from infrahub.events.utils import get_all_events +from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent +from infrahub.telemetry.task_manager import ( + count_webhook_runs, + count_windowed_event, + count_windowed_unique_resources, + gather_activity_24h, + gather_prefect_events, + gather_prefect_information, +) +from infrahub.telemetry.utils import floor_to_midnight_utc, get_activity_window +from infrahub.workflows.catalogue import WEBHOOK_PROCESS + +if TYPE_CHECKING: + from prefect.types import DateTime + +LOGIN_EVENT_NAME = AccountLoggedInEvent.event_name +WEBHOOK_FLOW_NAME = WEBHOOK_PROCESS.name + + +async def _post_events(client: PrefectClient, events: list[Event]) -> None: + """Send events with explicit ``occurred`` timestamps and wait for the last to be queryable. + + Raises: + Exception: If the last event is not queryable within the wait budget. + + """ + await client._client.post("/events", json=[event.model_dump(mode="json") for event in events]) + last_id = events[-1].id + body = {"filter": {"id": {"id": [str(last_id)]}}} + for _ in range(60): + response = await client._client.post("/events/filter", json=body) + response.raise_for_status() + if response.json().get("events"): + return + await asyncio.sleep(1) + raise Exception(f"Event {last_id} not found") + + +def _login_event(account_id: str, occurred: datetime) -> Event: + return Event( + id=uuid.uuid4(), + event=LOGIN_EVENT_NAME, + occurred=cast("DateTime", occurred), + resource=Resource({"prefect.resource.id": f"infrahub.account.{account_id}"}), + ) + + +def _named_event(event_name: str, occurred: datetime, resource_id: str) -> Event: + """Build a Prefect event of one name at an explicit instant. + + The windowed tally counts by event name over an ``occurred`` window, so only the name and + timestamp drive the assertions; the resource id is unique-per-event to keep records distinct. + """ + return Event( + id=uuid.uuid4(), + event=event_name, + occurred=cast("DateTime", occurred), + resource=Resource({"prefect.resource.id": resource_id}), + ) + + +@pytest.fixture(scope="module") +async def prefect_client(prefect_test_fixture: Generator[None]) -> AsyncGenerator[PrefectClient, None]: + async with get_client(sync_client=False) as client: + yield client + + +@pytest.fixture(scope="module") +async def seeded_logins(prefect_client: PrefectClient) -> str: + """Seed login events around the previous-UTC-day window; return the shared account suffix. + + The window the production code computes from "now" is the previous full UTC calendar day + [window_start, window_end). Events are placed relative to that real boundary: + - account ``a``: two logins inside the window (repeat → one unique bucket) + - account ``b``: one login inside the window + - account ``atstart``: one login at exactly window_start (included — half-open interval) + - account ``atend``: one login at exactly window_end (excluded — belongs to the next day; + counting it here as well would double-count it across two consecutive daily windows) + - account ``before``: one login one minute before window_start (excluded) + - account ``after``: one login one minute after window_end (excluded) + + Seeding relative to the real boundary (instead of a frozen clock) keeps the window the + gather later computes identical to the one used here, and proves the count is anchored to + midnight rather than to "now". + """ + window_start, window_end = get_activity_window() + suffix = uuid.uuid4().hex[:8] + acct_a = f"a-{suffix}" + acct_b = f"b-{suffix}" + in_window = window_start + timedelta(hours=12) + events = [ + _login_event(acct_a, in_window), + _login_event(acct_a, in_window + timedelta(minutes=5)), + _login_event(acct_b, in_window), + _login_event(f"atstart-{suffix}", window_start), + _login_event(f"atend-{suffix}", window_end), + _login_event(f"before-{suffix}", window_start - timedelta(minutes=1)), + _login_event(f"after-{suffix}", window_end + timedelta(minutes=1)), + ] + await _post_events(prefect_client, events) + return suffix + + +# Each check/artifact/branch metric gets a distinct in-window count so a mis-wired field (one +# reading another's event name) would not coincidentally pass. Every event name also gets one +# event placed just before window_start to prove out-of-window records are excluded. +_ACTIVITY_IN_WINDOW_COUNTS: dict[str, int] = { + ValidatorStartedEvent.event_name: 3, + ValidatorPassedEvent.event_name: 2, + ValidatorFailedEvent.event_name: 1, + ArtifactCreatedEvent.event_name: 2, + ArtifactUpdatedEvent.event_name: 3, + BranchCreatedEvent.event_name: 2, + BranchMergedEvent.event_name: 1, + BranchDeletedEvent.event_name: 3, +} + + +@pytest.fixture(scope="module") +async def seeded_activity(prefect_client: PrefectClient) -> dict[str, int]: + """Seed validator/artifact/branch events around the previous-UTC-day window. + + For each event name, places its mapped number of events inside the window and exactly one a + minute before window_start (which must be excluded). Returns the in-window count per event + name so the assertions read the expected totals from the same source that seeded them. + """ + window_start, _ = get_activity_window() + suffix = uuid.uuid4().hex[:8] + in_window = window_start + timedelta(hours=12) + out_of_window = window_start - timedelta(minutes=1) + events: list[Event] = [] + for event_name, count in _ACTIVITY_IN_WINDOW_COUNTS.items(): + for index in range(count): + events.append( + _named_event(event_name, in_window, f"{event_name}.{suffix}.in.{index}"), + ) + events.append(_named_event(event_name, out_of_window, f"{event_name}.{suffix}.out")) + await _post_events(prefect_client, events) + return dict(_ACTIVITY_IN_WINDOW_COUNTS) + + +async def test_window_is_previous_full_utc_day() -> None: + # Off-midnight "now" → window is the previous full UTC calendar day, anchored to midnight. + now = datetime(2026, 6, 28, 2, 37, 0, tzinfo=UTC) + window_start, window_end = get_activity_window(now=now) + assert window_start == datetime(2026, 6, 27, 0, 0, 0, tzinfo=UTC) + assert window_end == datetime(2026, 6, 28, 0, 0, 0, tzinfo=UTC) + assert window_end - window_start == timedelta(hours=24) + + +async def test_floor_to_midnight_utc() -> None: + floored = floor_to_midnight_utc(datetime(2026, 6, 28, 2, 37, 41, 123, tzinfo=UTC)) + assert floored == datetime(2026, 6, 28, 0, 0, 0, tzinfo=UTC) + + +async def test_windowed_logins_count(prefect_client: PrefectClient, seeded_logins: str) -> None: + window_start, window_end = get_activity_window() + count = await count_windowed_event.fn( + client=prefect_client, + event_name=LOGIN_EVENT_NAME, + window_start=window_start, + window_end=window_end, + ) + # Four in-window logins (two from account a, one from account b, one at exactly + # window_start). The event at exactly window_end and the before/after events are excluded — + # proving the interval is half-open and anchored to midnight, not to now. + assert count == 4 + + +async def test_windowed_unique_logins_count(prefect_client: PrefectClient, seeded_logins: str) -> None: + window_start, window_end = get_activity_window() + unique = await count_windowed_unique_resources.fn( + client=prefect_client, + event_name=LOGIN_EVENT_NAME, + window_start=window_start, + window_end=window_end, + ) + # Three distinct accounts in-window (a, b, atstart); account a's repeat login collapses. + assert unique == 3 + + +async def test_windowed_logins_exclude_out_of_window(prefect_client: PrefectClient, seeded_logins: str) -> None: + # A window entirely in the past (well before any seeded event) must count nothing. + past_start = datetime(2020, 1, 1, tzinfo=UTC) + past_end = datetime(2020, 1, 2, tzinfo=UTC) + count = await count_windowed_event.fn( + client=prefect_client, + event_name=LOGIN_EVENT_NAME, + window_start=past_start, + window_end=past_end, + ) + assert count == 0 + + +async def test_webhook_success_failure_split(prefect_client: PrefectClient) -> None: + flow_id = await prefect_client.create_flow_from_name(WEBHOOK_FLOW_NAME) + + async def seed(terminal: StateType | None) -> None: + response = await prefect_client._client.post("/flow_runs/", json={"flow_id": str(flow_id)}) + response.raise_for_status() + run_id = response.json()["id"] + await prefect_client.set_flow_run_state(run_id, State(type=StateType.RUNNING, name="Running"), force=True) + if terminal is not None: + await prefect_client.set_flow_run_state( + run_id, State(type=terminal, name=terminal.value.capitalize()), force=True + ) + + # The ephemeral server stamps start_time at wall-clock time on the RUNNING transition + # (a client-supplied state timestamp is rejected), so bracket the real seeding instant. + before = datetime.now(tz=UTC) - timedelta(minutes=1) + await seed(StateType.COMPLETED) + await seed(StateType.COMPLETED) + await seed(StateType.FAILED) + await seed(StateType.CRASHED) + await seed(None) # non-terminal RUNNING — counted as neither + after = datetime.now(tz=UTC) + timedelta(minutes=1) + + success, failure = await count_webhook_runs.fn( + client=prefect_client, + window_start=before, + window_end=after, + ) + assert success == 2 + assert failure == 2 + + +async def test_webhook_split_excludes_out_of_window(prefect_client: PrefectClient) -> None: + # No webhook-process run was stamped in this far-past window. + past_start = datetime(2020, 1, 1, tzinfo=UTC) + past_end = datetime(2020, 1, 2, tzinfo=UTC) + success, failure = await count_webhook_runs.fn( + client=prefect_client, + window_start=past_start, + window_end=past_end, + ) + assert success == 0 + assert failure == 0 + + +async def test_gather_activity_24h_logins(prefect_client: PrefectClient, seeded_logins: str) -> None: + data = await gather_activity_24h.fn(client=prefect_client) + # Login fields reflect exactly the in-window seeded events (incl. the one at exactly + # window_start; the one at exactly window_end belongs to the next day). + assert data.logins == 4 + assert data.unique_logins == 3 + # Webhook fields are present (an empty window is 0, not null). Webhook runs seeded by + # other tests are stamped at "today", which is after the previous-day window the gather + # computes, so this assertion does not depend on cross-test ordering for a 0. + assert data.webhooks_fired_success is not None + assert data.webhooks_fired_failure is not None + + +async def test_gather_activity_24h_checks_artifacts_branches( + prefect_client: PrefectClient, seeded_activity: dict[str, int] +) -> None: + # Each field equals exactly the in-window seeded count for its mapped event; the single + # out-of-window event per name is excluded, so the count never inflates past the in-window + # total. One gather covers every field: the flow computes them all in a single pass. + data = await gather_activity_24h.fn(client=prefect_client) + assert data.checks_started == seeded_activity[ValidatorStartedEvent.event_name] + assert data.checks_passed == seeded_activity[ValidatorPassedEvent.event_name] + assert data.checks_failed == seeded_activity[ValidatorFailedEvent.event_name] + assert data.artifacts_created == seeded_activity[ArtifactCreatedEvent.event_name] + assert data.artifacts_updated == seeded_activity[ArtifactUpdatedEvent.event_name] + assert data.branches_created == seeded_activity[BranchCreatedEvent.event_name] + assert data.branches_merged == seeded_activity[BranchMergedEvent.event_name] + assert data.branches_deleted == seeded_activity[BranchDeletedEvent.event_name] + + +async def test_gather_prefect_events_unchanged(prefect_client: PrefectClient, seeded_logins: str) -> None: + """The existing unwindowed tally still returns a count for every Infrahub event name.""" + events = await gather_prefect_events.fn(client=prefect_client) + expected_names = {event.event_name for event in get_all_events()} + assert set(events.keys()) == expected_names + # The unwindowed login tally includes the out-of-window boundary events too, so it is + # at least the five seeded logins — i.e. it is NOT the windowed count of 3. + assert events[LOGIN_EVENT_NAME] >= 5 async def test_gather_prefect_information(prefect_test_fixture: Generator) -> None: diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py new file mode 100644 index 00000000000..6d076ad89a7 --- /dev/null +++ b/backend/tests/component/telemetry/test_tasks.py @@ -0,0 +1,318 @@ +"""Component tests for the telemetry gather flow and payload resilience.""" + +import hashlib +import json +from collections.abc import AsyncGenerator +from datetime import UTC, datetime +from typing import Generator + +import pytest +from prefect.client.orchestration import get_client + +from infrahub import __version__, config +from infrahub.components import ComponentType +from infrahub.core import registry +from infrahub.core.constants import AccountStatus, InfrahubKind +from infrahub.core.initialization import create_branch +from infrahub.core.node import Node +from infrahub.core.schema.schema_branch import SchemaBranch +from infrahub.database import InfrahubDatabase +from infrahub.events.account_action import AccountLoggedInEvent +from infrahub.telemetry.constants import TELEMETRY_KIND, TELEMETRY_VERSION, RemoteSendStatus +from infrahub.telemetry.models import TelemetryAccountData, TelemetryActivity24hData, TelemetryData +from infrahub.telemetry.repository import TelemetrySnapshotRepository +from infrahub.telemetry.snapshot import TelemetrySnapshot +from infrahub.telemetry.task_manager import ( + count_webhook_runs, + count_windowed_event, + count_windowed_unique_resources, +) +from infrahub.telemetry.tasks import ( + AnonymousTelemetryGatherer, + DefaultAccountGatherer, + DefaultActiveBranchCounter, + DefaultActivityGatherer, + GathererInterface, + build_anonymous_telemetry_gatherer, + count_active_branches, + gather_account_information, +) +from infrahub.workers.dependencies import ( + build_component, + clear_singletons, + get_component, + get_database, + set_component_type, +) +from tests.adapters.cache import MemoryCache +from tests.adapters.message_bus import BusSimulator + +# A far-past day no test ever seeds into: proves genuine-empty -> 0 without shared server state. +_EMPTY_WINDOW_START = datetime(2000, 1, 1, tzinfo=UTC) +_EMPTY_WINDOW_END = datetime(2000, 1, 2, tzinfo=UTC) + +_ACTIVITY_FIELDS = ( + "logins", + "unique_logins", + "checks_started", + "checks_passed", + "checks_failed", + "artifacts_created", + "artifacts_updated", + "branches_created", + "branches_merged", + "branches_deleted", + "webhooks_fired_success", + "webhooks_fired_failure", +) + + +async def _create_account(db: InfrahubDatabase, name: str, status: str) -> None: + account = await Node.init(db=db, schema=InfrahubKind.ACCOUNT) + await account.new(db=db, name=name, account_type="User", password=" accountPassword123", status=status) + await account.save(db=db) + + +async def _create_account_group(db: InfrahubDatabase, name: str) -> None: + group = await Node.init(db=db, schema=InfrahubKind.ACCOUNTGROUP) + await group.new(db=db, name=name) + await group.save(db=db) + + +async def _store_snapshot(db: InfrahubDatabase, data: TelemetryData) -> TelemetrySnapshot: + """Persist a telemetry payload and return the saved snapshot.""" + data_dict = data.model_dump(mode="json") + checksum = hashlib.sha256(json.dumps(data_dict).encode()).hexdigest() + snapshot = TelemetrySnapshot( + kind=TELEMETRY_KIND, + payload_format=TELEMETRY_VERSION, + deployment_id=str(registry.id) if registry.id else "", + infrahub_version=__version__, + data=data_dict, + checksum=checksum, + remote_send_status=RemoteSendStatus.PENDING, + ) + repository = TelemetrySnapshotRepository(db=db) + await repository.save(snapshot) + return snapshot + + +async def test_gather_account_information_counts( + db: InfrahubDatabase, register_core_models_schema: SchemaBranch +) -> None: + # Two active + one inactive account, and two account groups. + await _create_account(db=db, name="active-one", status=AccountStatus.ACTIVE.value) + await _create_account(db=db, name="active-two", status=AccountStatus.ACTIVE.value) + await _create_account(db=db, name="inactive-one", status=AccountStatus.INACTIVE.value) + await _create_account_group(db=db, name="group-one") + await _create_account_group(db=db, name="group-two") + + data = await gather_account_information.fn(db=db) + + # Only the two active accounts are counted; the inactive one is excluded. + assert data.active == 2 + assert data.groups == 2 + + +async def test_active_branches_excludes_default_and_global( + db: InfrahubDatabase, register_core_models_schema: SchemaBranch +) -> None: + # The registry already holds the default (main) and global (-global-) branches. Add two + # open branches; only those two must be counted as active. + await create_branch(branch_name="feature-a", db=db) + await create_branch(branch_name="feature-b", db=db) + + # The default and global branches are present and excluded by the active count. + assert any(branch.is_default for branch in registry.branch.values()) + assert any(branch.is_global for branch in registry.branch.values()) + + assert await count_active_branches(db=db) == 2 + + +@pytest.fixture +async def telemetry_environment( + db: InfrahubDatabase, + register_core_models_schema: SchemaBranch, + prefect_test_fixture: Generator[None, None, None], +) -> AsyncGenerator[InfrahubDatabase, None]: + """Wire the in-memory cache and message-bus adapters and a heartbeating component. + + Overrides are restored and singletons cleared on teardown so nothing leaks between modules. + """ + previous_cache = config.OVERRIDE.cache + previous_message_bus = config.OVERRIDE.message_bus + previous_registry_id = registry.id + clear_singletons() + config.OVERRIDE.cache = MemoryCache() + config.OVERRIDE.message_bus = BusSimulator() + registry.id = "test-deployment" + set_component_type(ComponentType.API_SERVER) + # Build the component once so it heartbeats into the in-memory cache before list_workers. + await build_component() + try: + yield db + finally: + config.OVERRIDE.cache = previous_cache + config.OVERRIDE.message_bus = previous_message_bus + registry.id = previous_registry_id + clear_singletons() + + +async def _build_gatherer( + account_gatherer: GathererInterface[TelemetryAccountData] | None = None, + activity_gatherer: GathererInterface[TelemetryActivity24hData] | None = None, + active_branch_counter: GathererInterface[int] | None = None, +) -> AnonymousTelemetryGatherer: + """Build the gatherer with real collaborators, overriding any one with an injected double.""" + database = await get_database() + component = await get_component() + return AnonymousTelemetryGatherer( + database=database, + component=component, + account_gatherer=account_gatherer or DefaultAccountGatherer(db=database), + activity_gatherer=activity_gatherer or DefaultActivityGatherer(), + active_branch_counter=active_branch_counter or DefaultActiveBranchCounter(db=database), + ) + + +async def test_gather_full_payload_fields_present(telemetry_environment: InfrahubDatabase) -> None: + """A healthy gather populates every new field on the payload (presence, not exact values).""" + gatherer = await build_anonymous_telemetry_gatherer() + data = await gatherer.gather() + + assert isinstance(data, TelemetryData) + + assert data.accounts.active is not None + assert data.accounts.groups is not None + + assert data.branches.active is not None + + assert "corenode" in data.database.node_count + assert data.database.node_count["corenode"] is not None + + assert "user" in data.database.node_count + assert data.database.node_count["user"] is not None + + # An empty window is 0, not null, so every field is populated. + for field in _ACTIVITY_FIELDS: + assert getattr(data.activity_24h, field) is not None, field + + +class EmptyWindowActivityGatherer: + """Assemble activity_24h over a far-past window no test seeds, via the real counters. + + The sources succeed but legitimately count nothing, isolating the empty -> 0 case from the + session-shared event store other modules populate around the live window. + """ + + async def gather(self) -> TelemetryActivity24hData: + async with get_client(sync_client=False) as client: + logins = await count_windowed_event.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=_EMPTY_WINDOW_START, + window_end=_EMPTY_WINDOW_END, + ) + unique_logins = await count_windowed_unique_resources.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=_EMPTY_WINDOW_START, + window_end=_EMPTY_WINDOW_END, + ) + webhook_success, webhook_failure = await count_webhook_runs.fn( + client=client, window_start=_EMPTY_WINDOW_START, window_end=_EMPTY_WINDOW_END + ) + return TelemetryActivity24hData( + logins=logins, + unique_logins=unique_logins, + checks_started=0, + checks_passed=0, + checks_failed=0, + artifacts_created=0, + artifacts_updated=0, + branches_created=0, + branches_merged=0, + branches_deleted=0, + webhooks_fired_success=webhook_success, + webhooks_fired_failure=webhook_failure, + ) + + +async def test_gather_genuine_empty_activity_is_zero(telemetry_environment: InfrahubDatabase) -> None: + """An empty window yields 0 on the activity counts, never null (a succeeded-but-empty source).""" + gatherer = await _build_gatherer(activity_gatherer=EmptyWindowActivityGatherer()) + data = await gatherer.gather() + + assert data.activity_24h.logins == 0 + assert data.activity_24h.unique_logins == 0 + assert data.activity_24h.webhooks_fired_success == 0 + assert data.activity_24h.webhooks_fired_failure == 0 + + +class BoomAccountGatherer: + async def gather(self) -> TelemetryAccountData: + raise RuntimeError("accounts source unavailable") + + +class BoomActivityGatherer: + async def gather(self) -> TelemetryActivity24hData: + raise RuntimeError("activity source unavailable") + + +class BoomActiveBranchCounter: + async def gather(self) -> int: + raise RuntimeError("branch source unavailable") + + +async def test_gather_one_source_fails_others_populated_and_stored( + telemetry_environment: InfrahubDatabase, +) -> None: + """One failing source nulls only its own fields; the rest is populated and still storable.""" + db = telemetry_environment + + gatherer = await _build_gatherer(account_gatherer=BoomAccountGatherer()) + data = await gatherer.gather() + + assert data.accounts.active is None + assert data.accounts.groups is None + + assert data.branches.active is not None + assert data.database.node_count["corenode"] is not None + for field in _ACTIVITY_FIELDS: + assert getattr(data.activity_24h, field) is not None, field + + # The payload still persists end to end despite the failed source. + snapshot = await _store_snapshot(db=db, data=data) + repository = TelemetrySnapshotRepository(db=db) + stored = await repository.get_list(limit=1) + assert stored + assert str(stored[0].uuid) == str(snapshot.uuid) + + +async def test_gather_activity_source_fails_only_activity_null( + telemetry_environment: InfrahubDatabase, +) -> None: + """A failing activity source nulls the whole activity_24h object, leaving the rest intact.""" + gatherer = await _build_gatherer(activity_gatherer=BoomActivityGatherer()) + data = await gatherer.gather() + + for field in _ACTIVITY_FIELDS: + assert getattr(data.activity_24h, field) is None, field + + # Accounts and the active-branch count are unaffected. + assert data.accounts.active is not None + assert data.branches.active is not None + assert data.database.node_count["corenode"] is not None + + +async def test_gather_branch_source_fails_only_branch_active_null( + telemetry_environment: InfrahubDatabase, +) -> None: + """A failing active-branch counter nulls only branches.active; branches.total is intact.""" + gatherer = await _build_gatherer(active_branch_counter=BoomActiveBranchCounter()) + data = await gatherer.gather() + + assert data.branches.active is None + # branches.total is computed directly from the registry and is never nullable. + assert isinstance(data.branches.total, int) + assert data.accounts.active is not None diff --git a/backend/tests/functional/api/test_load_schema.py b/backend/tests/functional/api/test_load_schema.py index 9da7e5c771f..123c9d89579 100644 --- a/backend/tests/functional/api/test_load_schema.py +++ b/backend/tests/functional/api/test_load_schema.py @@ -601,13 +601,13 @@ async def test_remove_optional_text_field_value( assert schema.parent == "TestLocation" assert schema.children == "TestLocation" - async def test_schema_load_tolerates_non_write_and_unknown_fields( + async def test_schema_load_accepts_read_level_field_and_warns( self, initial_dataset: str, test_client: InfrahubTestClient, api_admin_token: str, ) -> None: - """Accept a payload carrying a read-level field plus an unknown field.""" + """Accept a payload carrying a read-level field and report it as a warning.""" payload = { "schemas": [ { @@ -616,14 +616,7 @@ async def test_schema_load_tolerates_non_write_and_unknown_fields( { "name": "Device", "namespace": "Test", - "attributes": [ - { - "name": "name", - "kind": "Text", - "inherited": True, - "not_a_real_field": "value", - } - ], + "attributes": [{"name": "name", "kind": "Text", "inherited": True}], } ], } @@ -637,6 +630,47 @@ async def test_schema_load_tolerates_non_write_and_unknown_fields( ) assert response.status_code == 200 + warnings = response.json()["warnings"] + assert [warning["message"] for warning in warnings] == [ + "'inherited' is a read-only field, the submitted value is ignored" + ] + assert warnings[0]["kinds"] == [{"kind": "TestDevice", "field": "name"}] + + async def test_schema_load_rejects_unknown_field( + self, + initial_dataset: str, + test_client: InfrahubTestClient, + api_admin_token: str, + ) -> None: + """Reject a payload carrying a field the contract does not know, naming the field.""" + payload = { + "schemas": [ + { + "version": "1.0", + "nodes": [ + { + "name": "Device", + "namespace": "Test", + "attributes": [{"name": "name", "kind": "Text", "not_a_real_field": "value"}], + } + ], + } + ] + } + + response = await test_client.post( + "/api/schema/load", + json=payload, + headers={"X-INFRAHUB-KEY": api_admin_token}, + ) + + assert response.status_code == 422 + messages = [item["msg"] for item in response.json()["detail"]] + assert len(messages) == 1, messages + assert ( + "nodes[0].attributes[0].not_a_real_field: Unknown field, it is not part of the schema " + "(received: 'value')" in messages[0] + ), messages[0] async def test_schema_load_rejects_out_of_enum_attribute_kind( self, @@ -675,15 +709,15 @@ async def test_schema_load_rejects_out_of_enum_attribute_kind( assert "Input tag 'NotARealKind' found using 'kind' does not match any of the expected tags" in message, message assert "(received: {'name': 'name', 'kind': 'NotARealKind'})" in message, message - async def test_schema_load_tolerates_non_write_and_unknown_fields_in_extensions( + async def test_schema_load_accepts_read_level_field_in_extensions( self, initial_dataset: str, test_client: InfrahubTestClient, api_admin_token: str, ) -> None: - """Accept an extension attribute carrying a read-level field and an unknown field.""" + """Accept an extension attribute carrying a read-level field, and report it as a warning.""" # Define a fresh node (no existing instances) so the extension is applicable, then extend it - # with an optional attribute carrying a read-level field and an unknown field. + # with an optional attribute carrying a read-level field. base = { "schemas": [ { @@ -709,15 +743,7 @@ async def test_schema_load_tolerates_non_write_and_unknown_fields_in_extensions( "nodes": [ { "kind": "TestGadget", - "attributes": [ - { - "name": "extra", - "kind": "Text", - "optional": True, - "inherited": True, - "not_a_real_field": "value", - } - ], + "attributes": [{"name": "extra", "kind": "Text", "optional": True, "inherited": True}], } ] }, @@ -732,6 +758,9 @@ async def test_schema_load_tolerates_non_write_and_unknown_fields_in_extensions( ) assert response.status_code == 200 + assert [warning["message"] for warning in response.json()["warnings"]] == [ + "'inherited' is a read-only field, the submitted value is ignored" + ] async def test_schema_load_rejects_out_of_enum_relationship_cardinality( self, @@ -884,9 +913,9 @@ async def test_write_contract_parity_sdk_offline_vs_load_endpoint( ) -> None: """The same payload yields the same verdict offline (SDK) and via POST /api/schema/load. - A valid payload passes both; a payload carrying only non-write/unknown fields is tolerated by - both (the extra fields are dropped); an out-of-enum value is rejected by both, and the invalid - value the SDK names offline appears in the server's rejection response. + A valid payload passes both; a payload carrying a read-level field is accepted by both with + the same warning; an unknown field and an out-of-enum value are rejected by both, and the + offending value the SDK names offline appears in the server's rejection response. """ valid_schema_root = { "version": "1.0", @@ -904,14 +933,17 @@ async def test_write_contract_parity_sdk_offline_vs_load_endpoint( { "name": "Device", "namespace": "Test", - "attributes": [ - { - "name": "name", - "kind": "Text", - "inherited": True, - "not_a_real_field": "value", - } - ], + "attributes": [{"name": "name", "kind": "Text", "inherited": True}], + } + ], + } + unknown_field_schema_root = { + "version": "1.0", + "nodes": [ + { + "name": "Device", + "namespace": "Test", + "attributes": [{"name": "name", "kind": "Text", "not_a_real_field": "value"}], } ], } @@ -936,15 +968,35 @@ async def test_write_contract_parity_sdk_offline_vs_load_endpoint( ) assert response_valid.status_code == 200 - # Non-write/unknown fields: tolerated offline and accepted by the server (the fields are dropped). + # Read-level field: accepted by both, and both name it as a warning rather than an error. offline_tolerated = validate_schema(schema=tolerated_schema_root) assert offline_tolerated.valid is True + assert [warning.name for warning in offline_tolerated.warnings] == ["inherited"] response_tolerated = await test_client.post( "/api/schema/load", json={"schemas": [tolerated_schema_root]}, headers={"X-INFRAHUB-KEY": api_admin_token}, ) assert response_tolerated.status_code == 200 + assert [warning["message"] for warning in response_tolerated.json()["warnings"]] == [ + "'inherited' is a read-only field, the submitted value is ignored" + ] + + # Unknown field: SDK offline verdict is "invalid" and the server rejects it (422) with the + # same field-level message. + offline_unknown = validate_schema(schema=unknown_field_schema_root) + assert offline_unknown.valid is False + assert len(offline_unknown.messages) == 1, offline_unknown.messages + unknown_message = offline_unknown.messages[0] + response_unknown = await test_client.post( + "/api/schema/load", + json={"schemas": [unknown_field_schema_root]}, + headers={"X-INFRAHUB-KEY": api_admin_token}, + ) + assert response_unknown.status_code == 422 + unknown_server_messages = [item["msg"] for item in response_unknown.json()["detail"]] + assert len(unknown_server_messages) == 1, unknown_server_messages + assert unknown_message in unknown_server_messages[0], (unknown_message, unknown_server_messages) # Out-of-enum value: SDK offline verdict is "invalid" and the server rejects it (422), # naming the invalid value the SDK named offline. diff --git a/backend/tests/helpers/db_query_counter.py b/backend/tests/helpers/db_query_counter.py new file mode 100644 index 00000000000..c65c107431b --- /dev/null +++ b/backend/tests/helpers/db_query_counter.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from collections import Counter +from typing import TYPE_CHECKING, Any + +from infrahub.database import InfrahubDatabase, InfrahubDatabaseMode + +if TYPE_CHECKING: + from neo4j import Record + + from infrahub.core.query import QueryType + + +class CountingInfrahubDatabase(InfrahubDatabase): + """Database that records how many queries were executed, keyed by query name.""" + + def __init__(self, query_counts: Counter[str] | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + # shared by reference so counts recorded by derived session/transaction instances land here + self.query_counts: Counter[str] = query_counts if query_counts is not None else Counter() + + @classmethod + def from_db(cls, db: InfrahubDatabase) -> CountingInfrahubDatabase: + """Build a counting database on the driver of an existing one.""" + return cls( + mode=InfrahubDatabaseMode.DRIVER, + driver=db._driver, + db_type=db.db_type, + default_neo4j_runtime=db.default_neo4j_runtime, + queries_names_to_config=db.queries_names_to_config, + ) + + def get_context(self) -> dict[str, Any]: + ctx = super().get_context() + ctx["query_counts"] = self.query_counts + return ctx + + def count_for(self, name: str) -> int: + return self.query_counts[name] + + def reset_counts(self) -> None: + self.query_counts.clear() + + async def execute_query_with_metadata( + self, + query: str, + params: dict[str, Any] | None = None, + name: str = "undefined", + context: dict[str, str] | None = None, + type: QueryType | None = None, + timeout_seconds: float | None = None, + ) -> tuple[list[Record], dict[str, Any]]: + self.query_counts[name] += 1 + return await super().execute_query_with_metadata( + query=query, params=params, name=name, context=context, type=type, timeout_seconds=timeout_seconds + ) diff --git a/backend/tests/helpers/merge_recompute/dataset.py b/backend/tests/helpers/merge_recompute/dataset.py index 3b29abed2ba..2eece1b4647 100644 --- a/backend/tests/helpers/merge_recompute/dataset.py +++ b/backend/tests/helpers/merge_recompute/dataset.py @@ -22,8 +22,12 @@ PROFILE_PEER_KIND = "TestingProfilePeer" -def build_profile_schema() -> SchemaRoot: - """Two kinds: a peer, and a main node carrying all three derived families.""" +def build_profile_schema(cross_relationship_hfid: bool = False) -> SchemaRoot: + """Two kinds: a peer, and a main node carrying all three derived families. + + With ``cross_relationship_hfid`` the node's human-friendly id reads the peer across the + relationship instead of only its own name, so a peer rename has to refresh the stored HFID. + """ peer = NodeSchema( name="ProfilePeer", namespace=PROFILE_NAMESPACE, @@ -41,7 +45,7 @@ def build_profile_schema() -> SchemaRoot: label="Profile Node", default_filter="name__value", display_label="{{ name__value }} via {{ peer__name__value }}", - human_friendly_id=["name__value"], + human_friendly_id=["name__value", "peer__name__value"] if cross_relationship_hfid else ["name__value"], uniqueness_constraints=[["name__value"]], attributes=[ AttributeSchema(name="name", kind="Text", optional=False, unique=True), @@ -233,6 +237,99 @@ def build_chain_schema_dict(levels: int = 3) -> dict: return {"version": "1.0", "nodes": nodes} +INTERFACE_NAMESPACE = "Testing" +DEVICE_KIND = "TestingDevice" +INTERFACE_KIND = "TestingInterface" + + +def build_interface_hfid_schema_dict() -> dict: + """A device and an interface whose identity reads the device across the relationship. + + The interface's human-friendly id and display label both read the device name, so its stored + identity depends on a peer attribute. Renaming the device must rewrite the stored id, the + cross-relationship case that a self-only id never reaches. + """ + return { + "version": "1.0", + "nodes": [ + { + "name": "Device", + "namespace": INTERFACE_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ name__value }}", + "attributes": [{"name": "name", "kind": "Text", "optional": False, "unique": True}], + }, + { + "name": "Interface", + "namespace": INTERFACE_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ device__name__value }} :: {{ name__value }}", + "human_friendly_id": ["name__value", "device__name__value"], + "uniqueness_constraints": [["device", "name__value"]], + "attributes": [{"name": "name", "kind": "Text", "optional": False}], + "relationships": [{"name": "device", "peer": DEVICE_KIND, "optional": False, "cardinality": "one"}], + }, + ], + } + + +LOCATION_NAMESPACE = "Testing" +METRO_KIND = "TestingMetro" +SITE_KIND = "TestingSite" +RACK_KIND = "TestingRack" + + +def build_location_cascade_schema_dict() -> dict: + """A metro -> site -> rack chain that propagates a top-level rename two hops. + + The site's short name is a computed attribute reading the metro name across the relationship. + The site display label reads the metro directly, so it refreshes on the first hop. The rack + display label reads the site's short name across its own relationship, so it moves only after + the site's short name is rewritten: the recompute has to chain from that write to the rack. A + self-only display label never exercises this second hop. + """ + return { + "version": "1.0", + "nodes": [ + { + "name": "Metro", + "namespace": LOCATION_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ name__value }}", + "attributes": [{"name": "name", "kind": "Text", "optional": False, "unique": True}], + }, + { + "name": "Site", + "namespace": LOCATION_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ metro__name__value }}-{{ name__value }}", + "attributes": [ + {"name": "name", "kind": "Text", "optional": False, "unique": True}, + { + "name": "shortname", + "kind": "Text", + "optional": True, + "read_only": True, + "computed_attribute": { + "kind": "Jinja2", + "jinja2_template": "{{ metro__name__value }}-{{ name__value }}", + }, + }, + ], + "relationships": [{"name": "metro", "peer": METRO_KIND, "optional": False, "cardinality": "one"}], + }, + { + "name": "Rack", + "namespace": LOCATION_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ site__shortname__value }} :: {{ name__value }}", + "attributes": [{"name": "name", "kind": "Text", "optional": False, "unique": True}], + "relationships": [{"name": "site", "peer": SITE_KIND, "optional": False, "cardinality": "one"}], + }, + ], + } + + @dataclass(frozen=True) class SeededDataset: branch: Branch diff --git a/backend/tests/helpers/number_pool.py b/backend/tests/helpers/number_pool.py new file mode 100644 index 00000000000..4070d7f7b04 --- /dev/null +++ b/backend/tests/helpers/number_pool.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +from infrahub.core import registry +from infrahub.core.constants import ComputedAttributeKind, InfrahubKind +from infrahub.core.node.resource_manager.number_pool import CoreNumberPool +from infrahub.core.schema import SchemaRoot +from infrahub.core.schema.attribute_parameters import NumberPoolParameters +from infrahub.core.schema.computed_attribute import ComputedAttribute +from infrahub.pools.schema_number_pool_synchronizer import SchemaNumberPoolSynchronizer +from infrahub.pools.schema_number_pool_upserter import SchemaNumberPoolUpserter +from tests.helpers.schema.snow import SNOW_INCIDENT, SNOW_TASK + +if TYPE_CHECKING: + from infrahub.core.branch import Branch + from infrahub.core.schema import AttributeSchema + from infrahub.database import InfrahubDatabase + + +def snow_schema_with_format_identifier( + identifier_template: str = "INC{{ '%09d' | format(number__value) }}", + extra_incident_attrs: list[AttributeSchema] | None = None, +) -> SchemaRoot: + """Snow schema whose incident identifier formats the pool value with a leading-zero filter.""" + task = copy.deepcopy(SNOW_TASK) + task.get_attribute(name="number").parameters = NumberPoolParameters(start_range=1, end_range=1000) + incident = copy.deepcopy(SNOW_INCIDENT) + incident.get_attribute(name="identifier").computed_attribute = ComputedAttribute( + kind=ComputedAttributeKind.JINJA2, jinja2_template=identifier_template + ) + if extra_incident_attrs: + incident.attributes.extend(extra_incident_attrs) + return SchemaRoot(generics=[task], nodes=[incident]) + + +async def register_and_provision_number_pools(db: InfrahubDatabase, branch: Branch, schema: SchemaRoot) -> None: + """Register the schema and provision the number pools defined by its NumberPool attributes.""" + registry.schema.register_schema(schema=schema, branch=branch.name) + registry.node[InfrahubKind.NUMBERPOOL] = CoreNumberPool + upserter = SchemaNumberPoolUpserter(db=db, schema_manager=registry.schema) + synchronizer = SchemaNumberPoolSynchronizer(db=db, schema_manager=registry.schema, upserter=upserter) + await synchronizer.run() diff --git a/backend/tests/integration/git/test_fingerprint_transformation.py b/backend/tests/integration/git/test_fingerprint_transformation.py index 9dffadc2b5c..f5841a0a03b 100644 --- a/backend/tests/integration/git/test_fingerprint_transformation.py +++ b/backend/tests/integration/git/test_fingerprint_transformation.py @@ -54,6 +54,33 @@ async def test_connected_query_edit_changes_transformation_fingerprint( after = (await client.get(kind=CoreTransformJinja2, name__value="person_with_cars")).fingerprint.value assert before != after + async def test_unrelated_commit_keeps_complete_jinja2_stable_but_folds_python( + self, repository_id: str, client: InfrahubClient, file_repo: FileRepo + ) -> None: + # Neither transform declares a watch, and the commit below touches neither of them. + # person_with_cars is a Jinja2 transform whose template includes nothing, so parsing it + # found every file that affects the output and the fingerprint can ignore the commit id. + # CarSpecMarkdown is a Python transform: its dependencies are just the files next to it + # on disk, which could always be missing an import from another directory, so its + # fingerprint keeps following the commit id. + jinja2_before = (await client.get(kind=CoreTransformJinja2, name__value="person_with_cars")).fingerprint.value + python_before = (await client.get(kind=CoreTransformPython, name__value="CarSpecMarkdown")).fingerprint.value + assert jinja2_before + assert python_before + + await self._commit_edit_and_reimport( + client=client, + repository_id=repository_id, + file_repo=file_repo, + edits={"README.md": _append_comment("README.md", file_repo)}, + ) + + jinja2_after = (await client.get(kind=CoreTransformJinja2, name__value="person_with_cars")).fingerprint.value + python_after = (await client.get(kind=CoreTransformPython, name__value="CarSpecMarkdown")).fingerprint.value + + assert jinja2_after == jinja2_before + assert python_after != python_before + def _append_comment(relative_path: str, file_repo: FileRepo) -> str: current = (Path(file_repo.path) / relative_path).read_text(encoding="utf-8") diff --git a/backend/tests/integration/git/test_sync_merged_branch.py b/backend/tests/integration/git/test_sync_merged_branch.py new file mode 100644 index 00000000000..de63cdb53ab --- /dev/null +++ b/backend/tests/integration/git/test_sync_merged_branch.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from git.repo import Repo + +from infrahub.core.branch.enums import BranchStatus +from infrahub.core.constants import InfrahubKind +from infrahub.core.initialization import create_branch +from infrahub.core.node import Node +from infrahub.git import InfrahubRepository +from tests.helpers.test_app import TestInfrahubApp + +if TYPE_CHECKING: + from pathlib import Path + + from infrahub_sdk import InfrahubClient + + from infrahub.database import InfrahubDatabase + from tests.helpers.file_repo import FileRepo + +MERGED_BRANCH = "description-field" + + +class TestSyncMergedBranch(TestInfrahubApp): + async def test_sync_skips_merged_branch( + self, + db: InfrahubDatabase, + client: InfrahubClient, + git_repo_car_dealership: FileRepo, + git_repos_dir: Path, + ) -> None: + """A merged (read-only) branch lingering on the remote must be skipped by the sync. + + Recording its commit issues CoreRepositoryUpdate, which is rejected for a merged branch. That + rejection is not isolated per branch, so it aborts the whole sync instead of skipping the one + branch. + """ + obj = await Node.init(schema=InfrahubKind.REPOSITORY, db=db) + await obj.new( + db=db, + name=git_repo_car_dealership.name, + description="test repository", + location=git_repo_car_dealership.path, + ) + await obj.save(db=db) + + repo = await InfrahubRepository.new( + id=obj.id, + name=git_repo_car_dealership.name, + location=git_repo_car_dealership.path, + client=client, + ) + + # The branch has been merged: it is read-only but its branch object persists in the graph. + branch = await create_branch(branch_name=MERGED_BRANCH, db=db) + branch.status = BranchStatus.MERGED + await branch.save(db=db) + + # The corresponding git branch is not deleted on merge, so it lingers on the remote and the + # local clone does not have it, making the sync treat it as a new branch to record. + Repo(git_repo_car_dealership.path).git.branch(MERGED_BRANCH, "main") + + collected = await repo.collect_pending_imports() + + assert MERGED_BRANCH not in [pending.infrahub_branch_name for pending in collected.imports] diff --git a/backend/tests/integration/merge/test_merge_uniqueness_field_scoping.py b/backend/tests/integration/merge/test_merge_uniqueness_field_scoping.py new file mode 100644 index 00000000000..a3ad7599ed4 --- /dev/null +++ b/backend/tests/integration/merge/test_merge_uniqueness_field_scoping.py @@ -0,0 +1,160 @@ +"""Merge-time uniqueness validation when only some changed fields participate in the constraint. + +The check is scoped to the nodes whose changed field participates in the kind's uniqueness, so a +diff mixing a participating and a non-participating change must still reach the participating one. +Both branches here change data only, keeping the node-scoped path in play — a branch that also +carries a schema change is validated against the whole population instead. +""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import pytest +from infrahub_sdk.exceptions import GraphQLError + +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node +from tests.constants import TestKind +from tests.helpers.schema import CAR_SCHEMA, load_schema +from tests.helpers.test_app import TestInfrahubApp + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient + + from infrahub.core.branch import Branch + from infrahub.core.schema import SchemaRoot + from infrahub.database import InfrahubDatabase + from tests.adapters.message_bus import BusSimulator + +BLOCKED_BRANCH = "uniqueness_scoping_relationship" +CLEAN_BRANCH = "uniqueness_scoping_unrelated" + +BRANCH_MERGE_MUTATION = """ +mutation($branch: String!) { + BranchMerge(data: { name: $branch }) { + ok + } +} +""" + + +async def _get_car(db: InfrahubDatabase, car_id: str, branch: Branch | str) -> Node: + return await NodeManager.get_one(db=db, id=car_id, branch=branch, kind=TestKind.CAR, raise_on_error=True) + + +class TestMergeUniquenessFieldScoping(TestInfrahubApp): + @pytest.fixture(scope="class") + def car_schema_unique_owner_name(self) -> SchemaRoot: + """Cars made unique by their owner together with their name. + + "owner" is a relationship and "name__value" an attribute, so a change to either half + implicates the constraint while "color"/"description" changes cannot. + """ + schema = copy.deepcopy(CAR_SCHEMA) + car = next(node for node in schema.nodes if node.kind == TestKind.CAR) + car.uniqueness_constraints = [["owner", "name__value"]] + return schema + + @pytest.fixture(scope="class") + async def initial_dataset( + self, + db: InfrahubDatabase, + initialize_registry: None, + client: InfrahubClient, + bus_simulator: BusSimulator, + prefect_test_fixture: None, + car_schema_unique_owner_name: SchemaRoot, + ) -> dict[str, str]: + """Two cars named "civic" kept distinct only by their owner, plus an unrelated "accord".""" + await load_schema(db, schema=car_schema_unique_owner_name) + + john = await Node.init(schema=TestKind.PERSON, db=db) + await john.new(db=db, name="John", height=175) + await john.save(db=db) + jane = await Node.init(schema=TestKind.PERSON, db=db) + await jane.new(db=db, name="Jane", height=165) + await jane.save(db=db) + honda = await Node.init(schema=TestKind.MANUFACTURER, db=db) + await honda.new(db=db, name="honda") + await honda.save(db=db) + + civic_john = await Node.init(schema=TestKind.CAR, db=db) + await civic_john.new(db=db, name="civic", color="blue", owner=john, manufacturer=honda) + await civic_john.save(db=db) + civic_jane = await Node.init(schema=TestKind.CAR, db=db) + await civic_jane.new(db=db, name="civic", color="red", owner=jane, manufacturer=honda) + await civic_jane.save(db=db) + accord_john = await Node.init(schema=TestKind.CAR, db=db) + await accord_john.new(db=db, name="accord", color="green", owner=john, manufacturer=honda) + await accord_john.save(db=db) + + return { + "john": john.id, + "jane": jane.id, + "civic_john": civic_john.id, + "civic_jane": civic_jane.id, + "accord_john": accord_john.id, + } + + async def test_participating_relationship_change_blocks_merge( + self, + db: InfrahubDatabase, + default_branch: Branch, + initial_dataset: dict[str, str], + client: InfrahubClient, + ) -> None: + """A re-owned car collides on (owner, name) even though no node changed the name attribute. + + The branch also changes another car's color, a field no constraint group reads, so the + participating change is the relationship one and it is the only reason to validate. + """ + await client.branch.create(branch_name=BLOCKED_BRANCH) + + civic_jane_branch = await _get_car(db=db, car_id=initial_dataset["civic_jane"], branch=BLOCKED_BRANCH) + await civic_jane_branch.get_relationship("owner").update(db=db, data=initial_dataset["john"]) + await civic_jane_branch.save(db=db) + accord_branch = await _get_car(db=db, car_id=initial_dataset["accord_john"], branch=BLOCKED_BRANCH) + accord_branch.get_attribute("color").value = "black" + await accord_branch.save(db=db) + + with pytest.raises(GraphQLError) as exc: + await client.execute_graphql(query=BRANCH_MERGE_MUTATION, variables={"branch": BLOCKED_BRANCH}) + + message = exc.value.message + civic_john_branch = await _get_car(db=db, car_id=initial_dataset["civic_john"], branch=BLOCKED_BRANCH) + for car in (civic_jane_branch, civic_john_branch): + display_label = await car.get_display_label(db=db) + assert ( + f"Node-level 'uniqueness_constraints' constraint violation on schema '{TestKind.CAR}'." + f" Node ({display_label}) is not compliant." + ) in message + # the car whose only change is a non-participating field is never implicated + assert await accord_branch.get_display_label(db=db) not in message + + # main keeps both owners, so it never holds two cars named "civic" with the same owner + civic_jane_main = await _get_car(db=db, car_id=initial_dataset["civic_jane"], branch=default_branch) + owner_main = await civic_jane_main.get_relationship("owner").get_peer(db=db, raise_on_error=True) + assert owner_main.id == initial_dataset["jane"] + accord_main = await _get_car(db=db, car_id=initial_dataset["accord_john"], branch=default_branch) + assert accord_main.get_attribute("color").value == "green" + + async def test_non_participating_change_alone_merges( + self, + db: InfrahubDatabase, + default_branch: Branch, + initial_dataset: dict[str, str], + client: InfrahubClient, + ) -> None: + """A branch touching only a field outside every constraint group merges without a violation.""" + await client.branch.create(branch_name=CLEAN_BRANCH) + + civic_john_branch = await _get_car(db=db, car_id=initial_dataset["civic_john"], branch=CLEAN_BRANCH) + civic_john_branch.get_attribute("description").value = "the blue one" + await civic_john_branch.save(db=db) + + await client.execute_graphql(query=BRANCH_MERGE_MUTATION, variables={"branch": CLEAN_BRANCH}) + + civic_john_main = await _get_car(db=db, car_id=initial_dataset["civic_john"], branch=default_branch) + assert civic_john_main.get_attribute("description").value == "the blue one" diff --git a/backend/tests/integration/schema_lifecycle/test_migration_hierarchy_change.py b/backend/tests/integration/schema_lifecycle/test_migration_hierarchy_change.py index 434f240a2c3..aa89fdb3f55 100644 --- a/backend/tests/integration/schema_lifecycle/test_migration_hierarchy_change.py +++ b/backend/tests/integration/schema_lifecycle/test_migration_hierarchy_change.py @@ -124,6 +124,17 @@ async def test_check_schema_02( schemas=[location_schema_02.model_dump(mode="json")], branch=branch_1.name ) assert success + # Submitting a full internal dump carries the fields Infrahub owns and derives itself, so + # the check reports each of them rather than dropping the values silently. + assert [warning["message"] for warning in response.pop("warnings")] == [ + "'hierarchy' is a read-only field, the submitted value is ignored", + "'used_by' is a read-only field, the submitted value is ignored", + "'inherited' is a read-only field, the submitted value is ignored", + "'parameters.id' is a read-only field, the submitted value is ignored", + "'parameters.state' is a read-only field, the submitted value is ignored", + "'extensions.id' is a read-only field, the submitted value is ignored", + "'extensions.state' is a read-only field, the submitted value is ignored", + ] assert response == { "diff": { "added": {"LocationMetro": {"added": {}, "changed": {}, "removed": {}}}, @@ -155,7 +166,6 @@ async def test_check_schema_02( }, }, }, - "warnings": [], } async def test_load_schema_02( diff --git a/backend/tests/integration/schema_lifecycle/test_schema_validator_rebase.py b/backend/tests/integration/schema_lifecycle/test_schema_validator_rebase.py index bbdf44decff..b9e98c9a349 100644 --- a/backend/tests/integration/schema_lifecycle/test_schema_validator_rebase.py +++ b/backend/tests/integration/schema_lifecycle/test_schema_validator_rebase.py @@ -186,9 +186,9 @@ async def test_step_02_node_unique_rebase_failure( with pytest.raises(GraphQLError) as exc: await client.branch.rebase(branch_name=branch_2.name) - assert initial_dataset["accord"] in exc.value.message - assert another_civic.id in exc.value.message - assert "node.uniqueness_constraints.update" in exc.value.message + assert initial_dataset["accord"] in exc.value.message + assert another_civic.id in exc.value.message + assert "node.uniqueness_constraints.update" in exc.value.message async def test_final_validate(self, db: InfrahubDatabase) -> None: await verify_no_duplicate_relationships(db=db) diff --git a/backend/tests/integration_docker/test_merge_recompute.py b/backend/tests/integration_docker/test_merge_recompute.py index 82939c5f391..e14e5e5b616 100644 --- a/backend/tests/integration_docker/test_merge_recompute.py +++ b/backend/tests/integration_docker/test_merge_recompute.py @@ -10,9 +10,16 @@ from infrahub_sdk.testing.docker import TestInfrahubDockerClient from tests.helpers.merge_recompute.dataset import ( + DEVICE_KIND, + INTERFACE_KIND, + METRO_KIND, PROFILE_NODE_KIND, PROFILE_PEER_KIND, + RACK_KIND, + SITE_KIND, build_chain_schema_dict, + build_interface_hfid_schema_dict, + build_location_cascade_schema_dict, build_profile_schema_dict, chain_kind, ) @@ -68,6 +75,14 @@ def profile_schema(self) -> dict: def chain_schema(self) -> dict: return build_chain_schema_dict(levels=3) + @pytest.fixture(scope="class") + def interface_hfid_schema(self) -> dict: + return build_interface_hfid_schema_dict() + + @pytest.fixture(scope="class") + def location_cascade_schema(self) -> dict: + return build_location_cascade_schema_dict() + @pytest.fixture(scope="class") def delete_peer_schema(self) -> dict: """Reuse the TShirt and Color helpers, dropping the transform-python attribute (it needs a transform repo) and making color optional.""" @@ -219,32 +234,93 @@ async def _on_destination() -> bool: assert final.display_label == "cnode via gamma" assert final.hfid == ["cnode"] - @pytest.mark.xfail( - strict=True, - reason=( - "the recompute locates readers with a reverse relationship query that returns nothing " - "once the deleted peer's edges are closed, so the reader keeps a value that still names " - "the deleted peer" - ), - ) + # Cross-relationship human-friendly id: the reader's identity reads a peer across the relationship, + # so a peer rename must reindex the stored hfid that backs get_one_by_hfid. These assert the stored + # value through the id lookup, not the id the SDK recomputes client-side from the loaded peer. + + async def test_merge_recomputes_cross_relationship_hfid( + self, client: InfrahubClient, interface_hfid_schema: dict + ) -> None: + """A merged peer rename reindexes the reader: it resolves under the new hfid and not the old one.""" + loaded = await client.schema.load(schemas=[interface_hfid_schema], wait_until_converged=True) + assert loaded.schema_updated + + device = await client.create(kind=DEVICE_KIND, data={"name": "device-a"}) + await device.save() + interface = await client.create(kind=INTERFACE_KIND, data={"name": "eth1", "device": device}) + await interface.save() + + async def _resolves(hfid: list[str]) -> bool: + return await client.get(kind=INTERFACE_KIND, hfid=hfid, raise_when_missing=False) is not None + + await _wait_until(lambda: _resolves(["eth1", "device-a"])) + + branch = await client.branch.create(branch_name="hfid-merge-correctness") + device_on_branch = await client.get(kind=DEVICE_KIND, id=device.id, branch=branch.name) + device_on_branch.name.value = "device-b" + await device_on_branch.save() + + merged = await client.branch.merge(branch_name=branch.name) + assert merged + await _wait_until_merged(client=client, branch_name=branch.name) + + assert await _became_true(lambda: _resolves(["eth1", "device-b"]), seconds=90) + assert (await client.get(kind=INTERFACE_KIND, hfid=["eth1", "device-b"])).id == interface.id + assert not await _resolves(["eth1", "device-a"]) + + async def test_rebase_recomputes_cross_relationship_hfid( + self, client: InfrahubClient, interface_hfid_schema: dict + ) -> None: + """A rebase that replays a peer rename reindexes a user-branch reader under its new hfid.""" + await client.schema.load(schemas=[interface_hfid_schema], wait_until_converged=True) + + device = await client.create(kind=DEVICE_KIND, data={"name": "rdevice-a"}) + await device.save() + + branch = await client.branch.create(branch_name="hfid-rebase-correctness") + + # Rename the device on the default branch; the rebase replays this onto the user branch. + device.name.value = "rdevice-b" + await device.save() + + # The interface lives only on the user branch, so only the rebase recompute can refresh it. + interface = await client.create( + kind=INTERFACE_KIND, data={"name": "reth1", "device": device}, branch=branch.name + ) + await interface.save() + + async def _resolves(hfid: list[str]) -> bool: + found = await client.get(kind=INTERFACE_KIND, hfid=hfid, branch=branch.name, raise_when_missing=False) + return found is not None + + await _wait_until(lambda: _resolves(["reth1", "rdevice-a"])) + + await client.branch.rebase(branch_name=branch.name) + + assert await _became_true(lambda: _resolves(["reth1", "rdevice-b"]), seconds=90) + assert ( + await client.get(kind=INTERFACE_KIND, hfid=["reth1", "rdevice-b"], branch=branch.name) + ).id == interface.id + assert not await _resolves(["reth1", "rdevice-a"]) + async def test_deleting_read_peer_refreshes_reader_after_merge(self, client: InfrahubClient) -> None: - """After a read peer is deleted and merged, the reader's derived values should stop naming it.""" + """After a read peer is deleted and merged, the reader's computed attribute stops naming it.""" schema = build_profile_schema_dict() node_schema = schema["nodes"][1] node_schema["relationships"][0]["optional"] = True - # Self-only display label so the scenario exercises the stale computed value, not the separate - # missing-peer diff crash that is fixed on its own path. + # Local display label: this test asserts the computed attribute's refresh, not the separate + # missing-peer diff crash for a cross-relationship label, which is fixed on its own path. node_schema["display_label"] = "{{ name__value }}" await client.schema.load(schemas=[schema], wait_until_converged=True) peer = await client.create(kind=PROFILE_PEER_KIND, data={"name": "beta"}) await peer.save() - node = await client.create(kind=PROFILE_NODE_KIND, data={"name": "node2", "peer": peer}) + node = await client.create(kind=PROFILE_NODE_KIND, data={"name": "dnode", "peer": peer}) await node.save() async def _reader_initial() -> bool: refreshed = await client.get(kind=PROFILE_NODE_KIND, id=node.id) - return refreshed.summary.value == "node2 on beta" + return refreshed.summary.value == "dnode on beta" await _wait_until(_reader_initial) @@ -256,11 +332,16 @@ async def _reader_initial() -> bool: assert merged await _wait_until_merged(client=client, branch_name=branch.name) - async def _reader_no_longer_names_peer() -> bool: + async def _reader_refreshed() -> bool: refreshed = await client.get(kind=PROFILE_NODE_KIND, id=node.id) - return refreshed.summary.value != "node2 on beta" + return refreshed.summary.value == "dnode on None" - assert await _became_true(_reader_no_longer_names_peer, seconds=60) + await _wait_until(_reader_refreshed) + + final = await client.get(kind=PROFILE_NODE_KIND, id=node.id) + assert final.summary.value == "dnode on None" + # HFID reads only the local name, so the delete leaves it unchanged. + assert final.hfid == ["dnode"] # Multi-level chain: level i reads level i-1 across the source relationship. @@ -384,6 +465,92 @@ async def _chain_on_branch_is(value: str) -> bool: assert final_mid.summary.value == "rroot-edited" assert final_tip.summary.value == "rroot-edited" + # Display-label cascade: a rename two hops up must refresh display labels down the chain. + + async def test_merge_recomputes_two_level_display_label_cascade( + self, client: InfrahubClient, location_cascade_schema: dict + ) -> None: + """A top-level rename must refresh both levels' display labels below it after merge.""" + loaded = await client.schema.load(schemas=[location_cascade_schema], wait_until_converged=True) + assert loaded.schema_updated + + metro = await client.create(kind=METRO_KIND, data={"name": "metro-a"}) + await metro.save() + site = await client.create(kind=SITE_KIND, data={"name": "site1", "metro": metro}) + await site.save() + rack = await client.create(kind=RACK_KIND, data={"name": "rack1", "site": site}) + await rack.save() + + async def _labels_are(metro_name: str) -> bool: + site_node = await client.get(kind=SITE_KIND, id=site.id) + rack_node = await client.get(kind=RACK_KIND, id=rack.id) + return ( + site_node.display_label == f"{metro_name}-site1" + and rack_node.display_label == f"{metro_name}-site1 :: rack1" + ) + + await _wait_until(lambda: _labels_are("metro-a")) + + branch = await client.branch.create(branch_name="cascade-merge-correctness") + metro_on_branch = await client.get(kind=METRO_KIND, id=metro.id, branch=branch.name) + metro_on_branch.name.value = "metro-b" + await metro_on_branch.save() + + merged = await client.branch.merge(branch_name=branch.name) + assert merged + await _wait_until_merged(client=client, branch_name=branch.name) + + await _wait_until(lambda: _labels_are("metro-b")) + + final_site = await client.get(kind=SITE_KIND, id=site.id) + final_rack = await client.get(kind=RACK_KIND, id=rack.id) + # First hop: the site reads the metro across its relationship. Second hop: the rack reads the + # site's short name, which only moves once the site's recompute writes it, so the rack + # refreshes only if the recompute chains from the site's write to the rack. + assert final_site.shortname.value == "metro-b-site1" + assert final_site.display_label == "metro-b-site1" + assert final_rack.display_label == "metro-b-site1 :: rack1" + + async def test_rebase_recomputes_two_level_display_label_cascade( + self, client: InfrahubClient, location_cascade_schema: dict + ) -> None: + """A rebase that replays a top-level rename must refresh both levels' display labels on the user branch.""" + await client.schema.load(schemas=[location_cascade_schema], wait_until_converged=True) + + metro = await client.create(kind=METRO_KIND, data={"name": "rmetro-a"}) + await metro.save() + + branch = await client.branch.create(branch_name="cascade-rebase-correctness") + # The site and rack live only on the user branch, so only the rebase recompute can refresh them. + site = await client.create(kind=SITE_KIND, data={"name": "rsite1", "metro": metro}, branch=branch.name) + await site.save() + rack = await client.create(kind=RACK_KIND, data={"name": "rrack1", "site": site}, branch=branch.name) + await rack.save() + + async def _labels_on_branch_are(metro_name: str) -> bool: + site_node = await client.get(kind=SITE_KIND, id=site.id, branch=branch.name) + rack_node = await client.get(kind=RACK_KIND, id=rack.id, branch=branch.name) + return ( + site_node.display_label == f"{metro_name}-rsite1" + and rack_node.display_label == f"{metro_name}-rsite1 :: rrack1" + ) + + await _wait_until(lambda: _labels_on_branch_are("rmetro-a")) + + # The rebase replays the default branch's metro rename onto the user branch. + metro.name.value = "rmetro-b" + await metro.save() + + await client.branch.rebase(branch_name=branch.name) + + await _wait_until(lambda: _labels_on_branch_are("rmetro-b")) + + final_site = await client.get(kind=SITE_KIND, id=site.id, branch=branch.name) + final_rack = await client.get(kind=RACK_KIND, id=rack.id, branch=branch.name) + assert final_site.shortname.value == "rmetro-b-rsite1" + assert final_site.display_label == "rmetro-b-rsite1" + assert final_rack.display_label == "rmetro-b-rsite1 :: rrack1" + # Delete a read peer: the merge must complete instead of erroring on the missing peer. async def test_merge_survives_deleting_a_read_peer(self, client: InfrahubClient, delete_peer_schema: dict) -> None: diff --git a/backend/tests/unit/api/test_schema_load_contract.py b/backend/tests/unit/api/test_schema_load_contract.py index b1edbee0306..1883093da56 100644 --- a/backend/tests/unit/api/test_schema_load_contract.py +++ b/backend/tests/unit/api/test_schema_load_contract.py @@ -5,10 +5,12 @@ import pytest from infrahub_sdk.schema import validate_schema +from infrahub_sdk.schema.generated.contract import READ_ONLY_FIELDS +from infrahub_sdk.schema.generated.write import InfrahubSchemaWrite -from infrahub.api.schema import SchemaLoadAPI +from infrahub.api.schema import SchemaLoadAPI, SchemaReadAPI from infrahub.core.constants import ComputedAttributeKind, HashableModelState -from infrahub.core.schema import SchemaRoot +from infrahub.core.schema import SchemaRoot, SchemaWarningType from tests.helpers.schema.snow import SNOW_INCIDENT, SNOW_REQUEST, SNOW_TASK @@ -26,7 +28,7 @@ class LoadContractCase: LOAD_CONTRACT_CASES = [ - LoadContractCase(name="full-internal-dump-tolerated", use_full_dump=True, accepted=True), + LoadContractCase(name="full-internal-dump-accepted", use_full_dump=True, accepted=True), LoadContractCase( name="minimal-write-payload", payload={ @@ -36,25 +38,53 @@ class LoadContractCase: accepted=True, ), LoadContractCase( - name="non-write-field-on-extension-tolerated", + name="unknown-field-on-extension-rejected", payload={"version": "1.0", "extensions": {"nodes": [{"kind": "BuiltinTag", "namespace": "Dropped"}]}}, - accepted=True, + accepted=False, ), LoadContractCase( - name="unknown-field-on-node-tolerated", + name="unknown-field-on-node-rejected", payload={"version": "1.0", "nodes": [{"namespace": "Test", "name": "Widget", "not_a_field": 1}]}, + accepted=False, + ), + LoadContractCase( + name="read-only-field-on-attribute-accepted", + payload={ + "version": "1.0", + "nodes": [ + { + "namespace": "Test", + "name": "Widget", + "attributes": [{"name": "field_one", "kind": "Text", "inherited": True, "state": "present"}], + } + ], + }, accepted=True, ), LoadContractCase( - name="non-write-and-unknown-fields-on-attribute-tolerated", + name="unknown-field-on-attribute-rejected", payload={ "version": "1.0", "nodes": [ { "namespace": "Test", "name": "Widget", - "attributes": [ - {"name": "field_one", "kind": "Text", "not_a_field": 1, "inherited": True, "state": "present"} + "attributes": [{"name": "field_one", "kind": "Text", "not_a_field": 1}], + } + ], + }, + accepted=False, + ), + LoadContractCase( + name="read-only-field-on-relationship-accepted", + payload={ + "version": "1.0", + "nodes": [ + { + "namespace": "Test", + "name": "Widget", + "relationships": [ + {"name": "gadgets", "peer": "TestGadget", "cardinality": "many", "inherited": True} ], } ], @@ -62,7 +92,7 @@ class LoadContractCase: accepted=True, ), LoadContractCase( - name="non-write-and-unknown-fields-on-relationship-tolerated", + name="unknown-field-on-relationship-rejected", payload={ "version": "1.0", "nodes": [ @@ -70,18 +100,12 @@ class LoadContractCase: "namespace": "Test", "name": "Widget", "relationships": [ - { - "name": "gadgets", - "peer": "TestGadget", - "cardinality": "many", - "not_a_field": 1, - "inherited": True, - } + {"name": "gadgets", "peer": "TestGadget", "cardinality": "many", "not_a_field": 1} ], } ], }, - accepted=True, + accepted=False, ), LoadContractCase( name="computed-attribute-accepted", @@ -100,7 +124,6 @@ class LoadContractCase: "computed_attribute": { "kind": "Jinja2", "jinja2_template": "{{ name__value }}", - "not_a_field": 1, }, } ], @@ -109,6 +132,30 @@ class LoadContractCase: }, accepted=True, ), + LoadContractCase( + name="unknown-field-on-computed-attribute-rejected", + payload={ + "version": "1.0", + "nodes": [ + { + "namespace": "Test", + "name": "Widget", + "attributes": [ + { + "name": "field_one", + "kind": "Text", + "computed_attribute": { + "kind": "Jinja2", + "jinja2_template": "{{ name__value }}", + "not_a_field": 1, + }, + } + ], + } + ], + }, + accepted=False, + ), LoadContractCase( name="computed-attribute-unknown-kind-rejected", payload={ @@ -164,49 +211,98 @@ def test_offline_validation_matches_load_contract_verdict(case: LoadContractCase assert result.valid is case.accepted, result.messages -def test_non_write_fields_do_not_reach_the_internal_schema() -> None: - # Non-write and unknown keys are tolerated at every nesting level, but a submitted value must - # never win over the server-owned one: ``inherited`` stays False even though the payload set it. - payload = { +def _read_only_payload() -> dict[str, Any]: + return { "version": "1.0", "nodes": [ { "namespace": "Test", "name": "Widget", - "not_a_field": 1, - "attributes": [ - {"name": "field_one", "kind": "Text", "inherited": True, "state": "absent", "not_a_field": 1} - ], - "relationships": [ - { - "name": "gadgets", - "peer": "TestGadget", - "cardinality": "many", - "inherited": True, - "not_a_field": 1, - } - ], + "hierarchy": "TestThing", + "attributes": [{"name": "field_one", "kind": "Text", "inherited": True, "state": "absent"}], + "relationships": [{"name": "gadgets", "peer": "TestGadget", "cardinality": "many", "inherited": True}], } ], - "extensions": {"nodes": [{"kind": "BuiltinTag", "namespace": "Dropped"}]}, } - loaded = SchemaLoadAPI.model_validate(payload) + +def test_read_only_fields_do_not_reach_the_internal_schema() -> None: + # A read-only field is accepted so a schema read back from Infrahub still loads, but the + # submitted value must never win over the server-owned one: ``inherited`` stays False. + loaded = SchemaLoadAPI.model_validate(_read_only_payload()) node = loaded.internal_schema.nodes[0] assert node.attributes[0].inherited is False assert node.relationships[0].inherited is False # ``state`` is settable, so the submitted value must survive where ``inherited`` did not assert node.attributes[0].state is HashableModelState.ABSENT - assert "not_a_field" not in loaded.model_dump()["nodes"][0] - assert "not_a_field" not in loaded.model_dump()["nodes"][0]["attributes"][0] - assert "not_a_field" not in loaded.model_dump()["nodes"][0]["relationships"][0] - assert "namespace" not in loaded.model_dump()["extensions"]["nodes"][0] + assert loaded.internal_schema.nodes[0].hierarchy is None + + +def test_read_only_fields_are_reported_as_warnings_grouped_by_field() -> None: + # The load response carries these back to the user, so each distinct read-only field is one + # warning naming every kind and element that set it, not one warning per occurrence. + loaded = SchemaLoadAPI.model_validate(_read_only_payload()) + + assert [warning.type for warning in loaded.contract_warnings] == [ + SchemaWarningType.DEPRECATION, + SchemaWarningType.DEPRECATION, + ] + reported = { + warning.message: sorted((kind.kind, kind.field) for kind in warning.kinds) + for warning in loaded.contract_warnings + } + assert reported == { + "'hierarchy' is a read-only field, the submitted value is ignored": [("TestWidget", None)], + "'inherited' is a read-only field, the submitted value is ignored": [ + ("TestWidget", "field_one"), + ("TestWidget", "gadgets"), + ], + } + + +def test_a_payload_without_read_only_fields_reports_no_warning() -> None: + payload = { + "version": "1.0", + "nodes": [{"namespace": "Test", "name": "Widget", "attributes": [{"name": "field_one", "kind": "Text"}]}], + } + + assert SchemaLoadAPI.model_validate(payload).contract_warnings == [] + + +def test_root_read_only_fields_cover_the_read_api_response() -> None: + # A raw GET /api/schema body resubmitted to the load endpoint must warn rather than fail, so + # every top-level key the read response adds over the write root is classified as read-only. + read_only = READ_ONLY_FIELDS[InfrahubSchemaWrite.__name__] + + assert set(SchemaReadAPI.model_fields) - set(InfrahubSchemaWrite.model_fields) == read_only + + +def test_unknown_field_is_rejected_naming_the_field() -> None: + payload = { + "version": "1.0", + "nodes": [ + { + "namespace": "Test", + "name": "Widget", + "attributes": [{"name": "field_one", "kind": "Text", "inheritd": True}], + } + ], + } + + with pytest.raises( + ValueError, + match=r"nodes\[0\]\.attributes\[0\]\.inheritd: Unknown field, it is not part of the schema " + r"\(received: True\)", + ): + SchemaLoadAPI.model_validate(payload) def test_computed_attribute_survives_its_discriminated_union() -> None: # Each attribute kind and computed-attribute kind is a separate variant of a discriminated - # union; dropping non-write keys must not collapse a variant onto the wrong one. + # union; dropping the fields of a sibling variant must not collapse a variant onto the wrong + # one. ``transform`` belongs to the TransformPython variant, so setting it on a Jinja2 one is + # tolerated with a warning rather than switching the variant. payload = { "version": "1.0", "nodes": [ @@ -222,7 +318,7 @@ def test_computed_attribute_survives_its_discriminated_union() -> None: "computed_attribute": { "kind": "Jinja2", "jinja2_template": "{{ name__value }}", - "not_a_field": 1, + "transform": "my_transform", }, } ], @@ -237,7 +333,12 @@ def test_computed_attribute_survives_its_discriminated_union() -> None: assert computed.kind is ComputedAttributeKind.JINJA2 assert computed.jinja2_template == "{{ name__value }}" assert computed.transform is None - assert "not_a_field" not in loaded.model_dump()["nodes"][0]["attributes"][0]["computed_attribute"] + assert "transform" not in loaded.model_dump()["nodes"][0]["attributes"][0]["computed_attribute"] + # Named relative to its owner: `transform` is settable on a TransformPython computed + # attribute, so the warning has to say which block the ignored value sat in. + assert [warning.message for warning in loaded.contract_warnings] == [ + "'computed_attribute.transform' is a read-only field, the submitted value is ignored" + ] def test_out_of_enum_attribute_kind_is_rejected_naming_the_value() -> None: diff --git a/backend/tests/unit/core/merge/test_build_coalesced_recompute.py b/backend/tests/unit/core/merge/test_build_coalesced_recompute.py index fdb3c0fb78c..188ed84d1cd 100644 --- a/backend/tests/unit/core/merge/test_build_coalesced_recompute.py +++ b/backend/tests/unit/core/merge/test_build_coalesced_recompute.py @@ -19,9 +19,9 @@ from tests.helpers.merge_recompute.dataset import PROFILE_NODE_KIND, PROFILE_PEER_KIND, build_profile_schema -def _profile_schema_branch() -> SchemaBranch: +def _profile_schema_branch(cross_relationship_hfid: bool = False) -> SchemaBranch: schema_branch = SchemaBranch(cache={}, name="test") - schema_branch.load_schema(schema=build_profile_schema()) + schema_branch.load_schema(schema=build_profile_schema(cross_relationship_hfid=cross_relationship_hfid)) schema_branch.process() return schema_branch @@ -35,13 +35,31 @@ def _lookups(target: AffectedTarget) -> set[tuple[str, str, frozenset[str]]]: def test_cross_node_update_coalesces_readers() -> None: - """Many changed peers collapse to one computed and one display target, one union lookup each.""" - builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) + """Many changed peers collapse to one computed and one display target, one union lookup each. + + The change set also carries kinds the derivation must tolerate rather than abort on: an updated + profile kind (not a NodeSchema) and a kind absent from the branch. Neither adds a target here. + """ + schema_branch = _profile_schema_branch() + builder = CoalescedRecomputeBuilder(schema_branch=schema_branch) peer_ids = {f"peer-{index:02d}" for index in range(5)} changes = [ MergeChange(node_id=peer_id, kind=PROFILE_PEER_KIND, action="updated", changed_fields=frozenset({"name"})) for peer_id in peer_ids ] + changes.extend( + [ + MergeChange( + node_id="profile-0", + kind=next(iter(schema_branch.profiles)), + action="updated", + changed_fields=frozenset({"name"}), + ), + MergeChange( + node_id="ghost-0", kind="TestingMissingKind", action="updated", changed_fields=frozenset({"name"}) + ), + ] + ) result = builder.build(changes=changes, branch="main") @@ -105,6 +123,33 @@ def test_deleted_node_refreshes_readers() -> None: assert _lookups(target) == {(PROFILE_PEER_KIND, "peer__ids", frozenset({"peer-gone"}))} +def test_relationship_change_recomputes_own_values_by_id() -> None: + """A relationship in an update's changed fields recomputes the node's own derived values by its id. + + This is the reader whose peer was deleted: it appears as an update on the relationship but was + never saved, so its cross-relationship values are not refreshed inline. + """ + builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) + changes = [ + MergeChange(node_id="node-0", kind=PROFILE_NODE_KIND, action="updated", changed_fields=frozenset({"peer"})) + ] + + result = builder.build(changes=changes, branch="main") + + # The relationship is in the changed fields, so this is the precise self path, not the fallback. + assert result.fallback_used is False + by_identity = _by_identity(result) + own = {(PROFILE_NODE_KIND, "ids", frozenset({"node-0"}))} + assert set(by_identity) == { + (COMPUTED_ATTRIBUTE, PROFILE_NODE_KIND, "summary"), + (DISPLAY_LABEL, PROFILE_NODE_KIND, None), + (HFID, PROFILE_NODE_KIND, None), + } + for target in by_identity.values(): + assert target.reads_across_relationship is False + assert _lookups(target) == own + + def test_hfid_does_not_fan_out_on_related_change() -> None: """A human-friendly id built from the local name is never recomputed by a related node's change.""" builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) @@ -117,6 +162,24 @@ def test_hfid_does_not_fan_out_on_related_change() -> None: assert not any(target.family == HFID for target in result.targets) +def test_hfid_fans_out_when_id_crosses_relationship() -> None: + """A human-friendly id that reads a peer across the relationship is recomputed by that peer's change. + + A merge and a rebase reach the builder identically, so this one derivation covers both: the reader's + id is scheduled alongside the display label and computed attribute that read the same peer. + """ + builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch(cross_relationship_hfid=True)) + changes = [ + MergeChange(node_id="peer-0", kind=PROFILE_PEER_KIND, action="updated", changed_fields=frozenset({"name"})) + ] + + result = builder.build(changes=changes, branch="main") + + hfid = _by_identity(result)[HFID, PROFILE_NODE_KIND, None] + assert hfid.reads_across_relationship is True + assert _lookups(hfid) == {(PROFILE_PEER_KIND, "peer__ids", frozenset({"peer-0"}))} + + def test_changes_to_same_target_are_deduplicated() -> None: """An update and a deletion of peers reach the same target once, with their ids unioned.""" builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) @@ -131,18 +194,32 @@ def test_changes_to_same_target_are_deduplicated() -> None: assert _lookups(computed) == {(PROFILE_PEER_KIND, "peer__ids", frozenset({"peer-0", "peer-1"}))} -def test_update_without_fields_is_a_bounded_fallback() -> None: - """An update with no recorded fields recomputes every cross-node reader and is marked imprecise.""" +def test_unscoped_update_is_a_bounded_fallback() -> None: + """An unscoped update imprecisely recomputes cross-node readers and the node's own derived values.""" builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) - changes = [MergeChange(node_id="peer-0", kind=PROFILE_PEER_KIND, action="updated", changed_fields=frozenset())] - - result = builder.build(changes=changes, branch="main") - # A peer's readers are the node computed summary and display label that read it across the - # relationship; no human-friendly id reads the peer, so even the unscoped fallback omits it. - assert set(_by_identity(result)) == { + # A peer: its cross-node readers (node summary and display) plus its own derived values. + peer_result = builder.build( + changes=[MergeChange(node_id="peer-0", kind=PROFILE_PEER_KIND, action="updated", changed_fields=frozenset())], + branch="main", + ) + assert set(_by_identity(peer_result)) == { (COMPUTED_ATTRIBUTE, PROFILE_NODE_KIND, "summary"), (DISPLAY_LABEL, PROFILE_NODE_KIND, None), + (DISPLAY_LABEL, PROFILE_PEER_KIND, None), + (HFID, PROFILE_PEER_KIND, None), } - assert result.fallback_used is True - assert all(target.precise is False for target in result.targets) + assert peer_result.fallback_used is True + assert all(target.precise is False for target in peer_result.targets) + + # A node: its own derived values, keyed by its own id. + node_result = builder.build( + changes=[MergeChange(node_id="node-0", kind=PROFILE_NODE_KIND, action="updated", changed_fields=frozenset())], + branch="main", + ) + by_identity = _by_identity(node_result) + own = {(PROFILE_NODE_KIND, "ids", frozenset({"node-0"}))} + assert _lookups(by_identity[COMPUTED_ATTRIBUTE, PROFILE_NODE_KIND, "summary"]) == own + assert _lookups(by_identity[DISPLAY_LABEL, PROFILE_NODE_KIND, None]) == own + assert _lookups(by_identity[HFID, PROFILE_NODE_KIND, None]) == own + assert node_result.fallback_used is True diff --git a/backend/tests/unit/core/schema/test_generated_visibility.py b/backend/tests/unit/core/schema/test_generated_visibility.py index b728fb50f33..2c6265c8ad2 100644 --- a/backend/tests/unit/core/schema/test_generated_visibility.py +++ b/backend/tests/unit/core/schema/test_generated_visibility.py @@ -3,9 +3,13 @@ import enum import types import typing +from dataclasses import dataclass import pytest +from infrahub_sdk.schema.generated import read as sdk_read from infrahub_sdk.schema.generated import write as sdk_write +from infrahub_sdk.schema.generated.contract import READ_ONLY_FIELDS +from pydantic import BaseModel from infrahub.core.constants import Visibility from infrahub.core.schema.definitions.internal import ( @@ -110,6 +114,78 @@ def test_write_model_publishes_allowed_values(family: str) -> None: ) +@dataclass(frozen=True) +class ReadOnlyDeltaCase: + name: str + write_model: type[BaseModel] + read_model: type[BaseModel] + + +READ_ONLY_DELTA_CASES = [ + ReadOnlyDeltaCase( + name="attribute", + write_model=sdk_write.AttributeSchemaBaseWrite, + read_model=sdk_read.AttributeSchemaBaseRead, + ), + ReadOnlyDeltaCase( + name="relationship", + write_model=sdk_write.RelationshipSchemaWrite, + read_model=sdk_read.RelationshipSchemaRead, + ), + ReadOnlyDeltaCase( + name="base_node", + write_model=sdk_write.BaseNodeSchemaWrite, + read_model=sdk_read.BaseNodeSchemaRead, + ), + ReadOnlyDeltaCase( + name="node", + write_model=sdk_write.NodeSchemaWrite, + read_model=sdk_read.NodeSchemaRead, + ), + ReadOnlyDeltaCase( + name="generic", + write_model=sdk_write.GenericSchemaWrite, + read_model=sdk_read.GenericSchemaRead, + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in READ_ONLY_DELTA_CASES]) +def test_read_only_table_matches_the_read_write_delta(case: ReadOnlyDeltaCase) -> None: + """The generated read-only table is exactly what the read model adds over the write model. + + The table decides whether an extra field in a submitted payload is a warning or an error, so a + field appearing on read but missing from the table would be rejected instead of tolerated, and + breaking the read-back, edit, re-load round trip. Each entry resolves what the class inherits, + so a consumer looks it up by name without walking the model hierarchy. + """ + # A computed field is part of the read payload even though it is not a model field. + read_names = set(case.read_model.model_fields) | set(case.read_model.model_computed_fields) + + assert READ_ONLY_FIELDS[case.write_model.__name__] == read_names - set(case.write_model.model_fields) + + +def test_read_only_table_entries_are_fully_resolved() -> None: + """No generated write class inherits a read-only field its own table entry omits. + + The offline validator looks a class up by name alone. Were the generator to emit only a class's + own read-only fields, an inherited one would fall through as an unknown field, turning a value + that must be tolerated on a round trip into a hard error. + """ + incomplete: dict[str, list[str]] = {} + for obj in vars(sdk_write).values(): + if not (isinstance(obj, type) and issubclass(obj, BaseModel)): + continue + inherited: set[str] = set() + for base in obj.__mro__[1:]: + inherited |= READ_ONLY_FIELDS.get(base.__name__, frozenset()) + missing = inherited - READ_ONLY_FIELDS.get(obj.__name__, frozenset()) + if missing: + incomplete[obj.__name__] = sorted(missing) + + assert incomplete == {} + + def test_attribute_kind_variants_partition_all_kinds() -> None: """The attribute union's variants together cover every attribute kind, without overlap. diff --git a/backend/tests/unit/core/test_attribute.py b/backend/tests/unit/core/test_attribute.py new file mode 100644 index 00000000000..79c26899fce --- /dev/null +++ b/backend/tests/unit/core/test_attribute.py @@ -0,0 +1,129 @@ +import re + +import pytest + +from infrahub.core.attribute import IPAddress, IPAddressOptional +from infrahub.core.branch import Branch +from infrahub.core.node import Node +from infrahub.core.schema import AttributeSchema, NodeSchema +from infrahub.core.timestamp import Timestamp +from infrahub.exceptions import ValidationError + + +@pytest.fixture +def branch() -> Branch: + return Branch(name="main") + + +@pytest.fixture +def ipaddress_schema() -> AttributeSchema: + return AttributeSchema(name="addr", kind="IPAddress") + + +def build_attribute( + schema: AttributeSchema, + branch: Branch, + data: str | None, + attribute_class: type[IPAddress] = IPAddress, +) -> IPAddress: + at = Timestamp() + node_schema = NodeSchema(name="DnsRecord", namespace="Test", attributes=[schema]) + node = Node(schema=node_schema, branch=branch, at=at) + + return attribute_class(name=schema.name, schema=schema, branch=branch, at=at, node=node, data=data) + + +@pytest.mark.parametrize( + "input_value", + [ + "10.0.0.1", + "255.255.255.255", + "0.0.0.0", # noqa: S104 + "2001:db8::1", + "::1", + "::ffff:10.0.0.1", + ], +) +def test_validate_format_ipaddress_accepts_bare_address( + branch: Branch, ipaddress_schema: AttributeSchema, input_value: str +) -> None: + build_attribute(schema=ipaddress_schema, branch=branch, data=input_value) + + +@pytest.mark.parametrize( + "input_value", + [ + "10.0.0.1/32", + "10.0.0.1/24", + "10.0.0.0/255.255.255.0", + "2001:db8::1/128", + "2001:db8::/64", + "010.0.0.1", + "10.0.0.256", + "10.0.1", + "not-an-ip", + "", + ], +) +def test_validate_format_ipaddress_rejects_prefix_and_garbage( + branch: Branch, ipaddress_schema: AttributeSchema, input_value: str +) -> None: + with pytest.raises(ValidationError, match=rf"^{re.escape(input_value)} is not a valid IPAddress at addr$"): + build_attribute(schema=ipaddress_schema, branch=branch, data=input_value) + + +def test_validate_ipaddress_returns(branch: Branch, ipaddress_schema: AttributeSchema) -> None: + test_ipv4 = build_attribute(schema=ipaddress_schema, branch=branch, data="10.0.2.1") + test_ipv6 = build_attribute(schema=ipaddress_schema, branch=branch, data="2001:db8::1") + + assert test_ipv4.value == "10.0.2.1" + assert test_ipv4.version == 4 + assert test_ipv4.ip_integer == 167772673 + assert test_ipv4.ip_binary == "00001010000000000000001000000001" + assert len(test_ipv4.ip_binary) == 32 + assert test_ipv4.to_db() == { + "binary_address": "00001010000000000000001000000001", + "is_default": False, + "prefixlen": 32, + "value": "10.0.2.1", + "version": 4, + } + + assert test_ipv6.value == "2001:db8::1" + assert test_ipv6.version == 6 + assert test_ipv6.ip_integer == 42540766411282592856903984951653826561 + assert test_ipv6.ip_binary == f"00100000000000010000110110111000{'0' * 95}1" + assert len(test_ipv6.ip_binary) == 128 + assert test_ipv6.to_db() == { + "binary_address": f"00100000000000010000110110111000{'0' * 95}1", + "is_default": False, + "prefixlen": 128, + "value": "2001:db8::1", + "version": 6, + } + + +def test_validate_ipaddress_returns_without_value(branch: Branch) -> None: + schema = AttributeSchema(name="addr", kind="IPAddress", optional=True) + + attr = build_attribute(schema=schema, branch=branch, data=None, attribute_class=IPAddressOptional) + + assert attr.value is None + assert attr.version is None + with pytest.raises(ValueError, match=r"^value for IPAddress must be defined$"): + _ = attr.obj + + +@pytest.mark.parametrize( + ("input_value", "normalized_value"), + [ + ("10.0.0.1", "10.0.0.1"), + ("2001:db8::1", "2001:db8::1"), + ("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::1"), + ("2001:DB8::1", "2001:db8::1"), + # an IPv4-mapped address keeps its family rather than collapsing to the IPv4 form + ("::ffff:10.0.0.1", "::ffff:10.0.0.1"), + ], +) +def test_ipaddress_normalizes_value(input_value: str, normalized_value: str) -> None: + assert IPAddress._normalize_value(input_value) == normalized_value diff --git a/backend/tests/unit/core/validators/test_checks_runner.py b/backend/tests/unit/core/validators/test_checks_runner.py new file mode 100644 index 00000000000..b76d271c059 --- /dev/null +++ b/backend/tests/unit/core/validators/test_checks_runner.py @@ -0,0 +1,130 @@ +"""Unit tests for the validator checks-runner's event emission. + +These pin down which validator lifecycle events the checks-runner emits, and — importantly — +which it does not. See the test docstring for why the ``started``/terminal split matters. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +import pytest + +from infrahub.auth.session import AccountSession +from infrahub.auth.types import AuthType +from infrahub.context import BranchContext, InfrahubContext +from infrahub.core.constants import ValidatorConclusion, ValidatorState +from infrahub.core.validators.checks_runner import run_checks_and_update_validator +from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent +from tests.adapters.event import MemoryInfrahubEvent + +if TYPE_CHECKING: + from collections.abc import Coroutine + + from infrahub_sdk.protocols import CoreValidator + + +@dataclass +class _Attr: + """Mutable stand-in for an SDK node attribute, exposing a single ``value``.""" + + value: object | None = None + + +class FakeValidator: + """Minimal stand-in for an SDK ``CoreValidator``. + + Which events fire depends only on the check results, not on persistence, so a fake with a + no-op ``save`` is enough — no real node or live API needed. + """ + + def __init__(self, *, validator_id: str, kind: str) -> None: + self.id = validator_id + self._kind = kind + self.state = _Attr() + self.conclusion = _Attr(ValidatorConclusion.UNKNOWN.value) + self.started_at = _Attr() + self.completed_at = _Attr() + self.save_count = 0 + + def get_kind(self) -> str: + return self._kind + + async def save(self) -> None: + self.save_count += 1 + + +async def _check(conclusion: ValidatorConclusion) -> ValidatorConclusion: + return conclusion + + +@dataclass +class CheckRunnerCase: + name: str + check_results: list[ValidatorConclusion] + expected_events: list[type] + expected_conclusion: str + + +CASES = [ + CheckRunnerCase( + name="no_checks_concludes_success", + check_results=[], + expected_events=[ValidatorPassedEvent], + expected_conclusion=ValidatorConclusion.SUCCESS.value, + ), + CheckRunnerCase( + name="all_checks_pass", + check_results=[ValidatorConclusion.SUCCESS, ValidatorConclusion.SUCCESS], + expected_events=[ValidatorPassedEvent], + expected_conclusion=ValidatorConclusion.SUCCESS.value, + ), + CheckRunnerCase( + name="a_failing_check_fails_the_validator", + check_results=[ValidatorConclusion.SUCCESS, ValidatorConclusion.FAILURE], + expected_events=[ValidatorFailedEvent], + expected_conclusion=ValidatorConclusion.FAILURE.value, + ), +] + + +def _make_context() -> InfrahubContext: + return InfrahubContext( + branch=BranchContext(name="main", id="00000000-0000-0000-0000-000000000000"), + account=AccountSession(account_id="00000000-0000-0000-0000-000000000001", auth_type=AuthType.API), + ) + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case.name) +async def test_run_checks_and_update_validator_emits_terminal_event_but_never_started( + case: CheckRunnerCase, +) -> None: + """The checks-runner emits a terminal (passed/failed) event but never a ``started`` event. + + ``started`` comes from a separate step, so a validator that concludes without going through + this function emits ``started`` but no terminal event — which is why a successful validator + can be counted in ``checks_started`` yet not ``checks_passed``. + """ + event_service = MemoryInfrahubEvent() + validator = FakeValidator(validator_id="18b00000-0000-0000-0000-0000000000aa", kind="CoreDataValidator") + checks = cast( + "list[Coroutine[Any, None, ValidatorConclusion]]", + [_check(result) for result in case.check_results], + ) + + await run_checks_and_update_validator( + checks=checks, + validator=cast("CoreValidator", validator), + context=_make_context(), + event_service=event_service, + proposed_change_id="18b00000-0000-0000-0000-0000000000bb", + ) + + emitted = [type(event) for event in event_service.events] + assert emitted == case.expected_events + # The crux: the checks-runner is the sole emitter of terminal events and never emits started. + assert ValidatorStartedEvent not in emitted + # The validator did complete — so a completed-successfully validator still emits no started. + assert validator.state.value == ValidatorState.COMPLETED.value + assert validator.conclusion.value == case.expected_conclusion diff --git a/backend/tests/unit/core/validators/test_constraint_deduplicator.py b/backend/tests/unit/core/validators/test_constraint_deduplicator.py index f02604e087f..ab12d1d4ec0 100644 --- a/backend/tests/unit/core/validators/test_constraint_deduplicator.py +++ b/backend/tests/unit/core/validators/test_constraint_deduplicator.py @@ -77,91 +77,91 @@ def _remaining_uniqueness_kinds(constraints: list[SchemaUpdateConstraintInfo]) - class TestUniquenessConstraintDeduplicator: def test_inherited_node_dropped_when_generic_covers_it_full_population(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [_uniqueness_info("TestCar"), _uniqueness_info("TestElectricCar")] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar"} def test_all_implementers_dropped_when_generic_covers_them(self) -> None: # the generic's scope is the union of the implementers' changed nodes - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [ _uniqueness_info("TestCar", node_uuids=["e1", "g1"]), _uniqueness_info("TestElectricCar", node_uuids=["e1"]), _uniqueness_info("TestGazCar", node_uuids=["g1"]), ] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar"} def test_node_with_own_group_is_kept(self) -> None: # SpecialCar has a `special` group the generic does not cover, so it cannot be dropped - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [_uniqueness_info("TestCar"), _uniqueness_info("TestSpecialCar")] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar", "TestSpecialCar"} def test_full_population_node_not_dropped_for_scoped_generic(self) -> None: # dropping a full-population node for a scoped generic would silently narrow the check - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [ _uniqueness_info("TestCar", node_uuids=["e1"]), _uniqueness_info("TestElectricCar", node_uuids=None), ] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar", "TestElectricCar"} def test_node_not_dropped_when_generic_scope_does_not_cover_it(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [ _uniqueness_info("TestCar", node_uuids=["other"]), _uniqueness_info("TestElectricCar", node_uuids=["e1"]), ] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar", "TestElectricCar"} def test_scoped_node_dropped_when_generic_scope_is_superset(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [ _uniqueness_info("TestCar", node_uuids=["e1", "e2", "g1"]), _uniqueness_info("TestElectricCar", node_uuids=["e1", "e2"]), ] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar"} def test_node_kept_when_generic_absent_from_set(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [_uniqueness_info("TestElectricCar")] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestElectricCar"} def test_standalone_node_is_kept(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [_uniqueness_info("TestCar"), _uniqueness_info("TestPerson")] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar", "TestPerson"} def test_non_uniqueness_constraints_pass_through(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() attribute = _attribute_info("TestElectricCar") constraints = [_uniqueness_info("TestCar"), _uniqueness_info("TestElectricCar"), attribute] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar"} assert attribute in result diff --git a/backend/tests/unit/core/validators/test_constraint_merge.py b/backend/tests/unit/core/validators/test_constraint_merge.py index 269a4eefb27..ac1aca1fa6b 100644 --- a/backend/tests/unit/core/validators/test_constraint_merge.py +++ b/backend/tests/unit/core/validators/test_constraint_merge.py @@ -46,27 +46,28 @@ class TestConstraintInfoMergerPrecedence: def test_full_scan_wins_over_node_scoped(self) -> None: # a constraint both broadened (schema diff, full scan) and data-changed collapses to a full scan - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() data_diff = [_uniqueness_info("TestCar", node_uuids=["a", "b"])] schema_diff = [_uniqueness_info("TestCar", node_uuids=None)] - result = merger.merge(data_diff, schema_diff) + result = merger.merge(_schema_branch(), data_diff, schema_diff) assert result == [_uniqueness_info("TestCar", node_uuids=None)] def test_full_scan_wins_regardless_of_order(self) -> None: - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() schema_diff = [_uniqueness_info("TestCar", node_uuids=None)] data_diff = [_uniqueness_info("TestCar", node_uuids=["a", "b"])] - result = merger.merge(schema_diff, data_diff) + result = merger.merge(_schema_branch(), schema_diff, data_diff) assert result == [_uniqueness_info("TestCar", node_uuids=None)] def test_two_node_scoped_entries_union_their_nodes(self) -> None: - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() result = merger.merge( + _schema_branch(), [_uniqueness_info("TestCar", node_uuids=["a", "b"])], [_uniqueness_info("TestCar", node_uuids=["b", "c"])], ) @@ -74,9 +75,10 @@ def test_two_node_scoped_entries_union_their_nodes(self) -> None: assert result == [_uniqueness_info("TestCar", node_uuids=["a", "b", "c"])] def test_distinct_constraints_are_preserved(self) -> None: - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() result = merger.merge( + _schema_branch(), [_uniqueness_info("TestCar", node_uuids=["a"])], [_uniqueness_info("TestPerson", node_uuids=["b"])], ) @@ -88,14 +90,14 @@ class TestConstraintInfoMerger: def test_merges_then_deduplicates(self) -> None: # the data diff scopes the generic; the schema diff broadens it to the full population; and # the inherited node check must be dropped as covered by the generic - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() data_diff = [ _uniqueness_info("TestCar", node_uuids=["e1"]), _uniqueness_info("TestElectricCar", node_uuids=["e1"]), ] schema_diff = [_uniqueness_info("TestCar", node_uuids=None)] - result = merger.merge(data_diff, schema_diff) + result = merger.merge(_schema_branch(), data_diff, schema_diff) # merge precedence keeps the full-population generic; dedup then removes the covered node assert result == [_uniqueness_info("TestCar", node_uuids=None)] diff --git a/backend/tests/unit/git/fingerprint/test_composer_transformation.py b/backend/tests/unit/git/fingerprint/test_composer_transformation.py index 5cb37ec31a4..46c537ebdd3 100644 --- a/backend/tests/unit/git/fingerprint/test_composer_transformation.py +++ b/backend/tests/unit/git/fingerprint/test_composer_transformation.py @@ -1,5 +1,8 @@ from __future__ import annotations +from dataclasses import dataclass + +import pytest from infrahub_sdk.schema.repository import InfrahubWatchConfig from infrahub.git.closure_builder.post_processing import MANIFEST_PATH @@ -151,3 +154,63 @@ def digest(blobs: dict[str, str]) -> str: return build_composer(blob_shas=blobs, registry=registry).compose_transformation(_jinja2_input()) assert digest(J2_BLOBS) == digest({**J2_BLOBS, MANIFEST_PATH: "sha-manifest-edited"}) + + +@dataclass +class Jinja2CommitFoldCase: + name: str + watch: InfrahubWatchConfig | None + dependencies_complete: bool + seed_query: bool + stable: bool + + +JINJA2_COMMIT_FOLD_CASES = [ + Jinja2CommitFoldCase( + name="complete_closure_without_watch_is_stable", + watch=None, + dependencies_complete=True, + seed_query=True, + stable=True, + ), + Jinja2CommitFoldCase( + name="complete_closure_with_empty_watch_is_stable", + watch=InfrahubWatchConfig(files=[]), + dependencies_complete=True, + seed_query=True, + stable=True, + ), + Jinja2CommitFoldCase( + name="incomplete_closure_without_watch_folds_commit_id", + watch=None, + dependencies_complete=False, + seed_query=True, + stable=False, + ), + Jinja2CommitFoldCase( + name="unresolved_query_folds_commit_id_despite_complete_closure", + watch=None, + dependencies_complete=True, + seed_query=False, + stable=False, + ), +] + + +@pytest.mark.parametrize("case", JINJA2_COMMIT_FOLD_CASES, ids=lambda case: case.name) +def test_jinja2_commit_id_folding(case: Jinja2CommitFoldCase) -> None: + # A Jinja2 transform's dependency list is parsed out of the template, so a complete one is + # trusted on its own: the fingerprint stays the same across unrelated commits with no watch + # declared. If a reference could not be followed, or the query the transform reads was never + # fingerprinted, the commit id goes back in and the fingerprint changes on every commit. + def digest(*, commit: str) -> str: + registry = FingerprintRegistry() + if case.seed_query: + _seed_query(registry) + return build_composer(blob_shas=J2_BLOBS, commit=commit, registry=registry).compose_transformation( + _jinja2_input(watch=case.watch, dependencies_complete=case.dependencies_complete) + ) + + first = digest(commit="commit-1") + second = digest(commit="commit-2") + assert (first == second) is case.stable diff --git a/backend/tests/unit/git/fingerprint/test_watch_state.py b/backend/tests/unit/git/fingerprint/test_watch_state.py index cd364ada478..2c9da536ee2 100644 --- a/backend/tests/unit/git/fingerprint/test_watch_state.py +++ b/backend/tests/unit/git/fingerprint/test_watch_state.py @@ -1,31 +1,89 @@ from __future__ import annotations +from dataclasses import dataclass + +import pytest from infrahub_sdk.schema.repository import InfrahubWatchConfig from infrahub.git.fingerprint.composer import fold_commit_id -def test_absent_watch_folds_commit_id() -> None: - assert fold_commit_id(commit="commit-1", watch=None, closure_complete=True) == "commit-1" +@dataclass +class FoldCase: + name: str + watch: InfrahubWatchConfig | None + closure_complete: bool + watch_required: bool + folds: bool -def test_present_empty_watch_omits_commit_id() -> None: - assert fold_commit_id(commit="commit-1", watch=InfrahubWatchConfig(files=[]), closure_complete=True) is None +FOLD_CASES = [ + # watch_required=True is how Python transforms and generators behave: their dependency list + # is only the files next to the source file, so it can be missing an import from elsewhere + # and a hand-written watch is the only thing that makes the fingerprint stable. + FoldCase( + name="watch_required_absent_watch_folds", + watch=None, + closure_complete=True, + watch_required=True, + folds=True, + ), + FoldCase( + name="watch_required_empty_watch_omits", + watch=InfrahubWatchConfig(files=[]), + closure_complete=True, + watch_required=True, + folds=False, + ), + FoldCase( + name="watch_required_populated_watch_omits", + watch=InfrahubWatchConfig(files=["helpers/util.py"]), + closure_complete=True, + watch_required=True, + folds=False, + ), + FoldCase( + name="watch_required_incomplete_closure_folds_despite_watch", + watch=InfrahubWatchConfig(files=[]), + closure_complete=False, + watch_required=True, + folds=True, + ), + # watch_required=False is how Jinja2 transforms behave: their dependency list is parsed out + # of the template, so a complete one needs no watch to make the fingerprint stable. An + # incomplete list means a reference could not be followed, which still folds the commit id. + FoldCase( + name="watch_optional_absent_watch_complete_closure_omits", + watch=None, + closure_complete=True, + watch_required=False, + folds=False, + ), + FoldCase( + name="watch_optional_absent_watch_incomplete_closure_folds", + watch=None, + closure_complete=False, + watch_required=False, + folds=True, + ), +] -def test_present_populated_watch_omits_commit_id() -> None: - watch = InfrahubWatchConfig(files=["helpers/util.py"]) - assert fold_commit_id(commit="commit-1", watch=watch, closure_complete=True) is None +@pytest.mark.parametrize("case", FOLD_CASES, ids=lambda case: case.name) +def test_fold_commit_id(case: FoldCase) -> None: + result = fold_commit_id( + commit="commit-1", + watch=case.watch, + closure_complete=case.closure_complete, + watch_required=case.watch_required, + ) + assert result == ("commit-1" if case.folds else None) -def test_absent_and_present_empty_are_distinct_states() -> None: - absent = fold_commit_id(commit="commit-1", watch=None, closure_complete=True) - present_empty = fold_commit_id(commit="commit-1", watch=InfrahubWatchConfig(files=[]), closure_complete=True) - assert absent is not None +def test_absent_and_present_empty_are_distinct_states_when_watch_required() -> None: + absent = fold_commit_id(commit="commit-1", watch=None, closure_complete=True, watch_required=True) + present_empty = fold_commit_id( + commit="commit-1", watch=InfrahubWatchConfig(files=[]), closure_complete=True, watch_required=True + ) + assert absent == "commit-1" assert present_empty is None - - -def test_incomplete_closure_folds_commit_id_even_with_present_watch() -> None: - # An incomplete closure means an output-affecting dependency is unknown, so the commit - # id is folded to avoid a stable fingerprint over an unknown input set. - assert fold_commit_id(commit="commit-1", watch=InfrahubWatchConfig(files=[]), closure_complete=False) == "commit-1" diff --git a/backend/tests/unit/git/test_transform_jinja2_information.py b/backend/tests/unit/git/test_transform_jinja2_information.py new file mode 100644 index 00000000000..a72f20f95a6 --- /dev/null +++ b/backend/tests/unit/git/test_transform_jinja2_information.py @@ -0,0 +1,142 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest +from infrahub_sdk import Config, InfrahubClient +from infrahub_sdk.node import InfrahubNode +from infrahub_sdk.protocols import CoreTransformJinja2 +from infrahub_sdk.schema import NodeSchemaAPI + +from infrahub.core.schema import SchemaRoot, core_models, internal_schema +from infrahub.core.schema.schema_branch import SchemaBranch +from infrahub.git.integrator import InfrahubRepositoryIntegrator, InfrahubRepositoryJinja2 + + +def _load_core_node_schema(kind: str) -> NodeSchemaAPI: + schema_branch = SchemaBranch(cache={}, name="test") + schema_branch.load_schema(schema=SchemaRoot(**internal_schema)) + schema_branch.load_schema(schema=SchemaRoot(**core_models)) + # Flatten inherited attributes/relationships so the node schema carries the full field set. + schema_branch.process_inheritance() + return NodeSchemaAPI(**schema_branch.get(name=kind, duplicate=False).model_dump()) + + +TRANSFORM_JINJA2_SCHEMA = _load_core_node_schema("CoreTransformJinja2") +EXISTING_TRANSFORM_ID = "a0d4c22a-5f60-4bf9-a53f-f9a335420492" + + +def _make_existing_transform( + query_id: str = "query-id", + template_path: str = "templates/test.j2", + description: str | None = None, + dependencies: list[str] | None = None, + dependencies_complete: bool = False, +) -> CoreTransformJinja2: + client = InfrahubClient(config=Config(address="http://mock")) + data = { + "id": EXISTING_TRANSFORM_ID, + "__typename": "CoreTransformJinja2", + "display_label": "test-transform", + "name": {"value": "test", "__typename": "Text"}, + "label": {"value": "Test", "__typename": "Text"}, + "description": {"value": description, "__typename": "Text"}, + "template_path": {"value": template_path, "__typename": "Text"}, + "dependencies": {"value": dependencies if dependencies is not None else [], "__typename": "List"}, + "dependencies_complete": {"value": dependencies_complete, "__typename": "Boolean"}, + "query": { + "node": {"id": query_id, "display_label": "test-query", "__typename": "CoreGraphQLQuery"}, + "__typename": "NestedEdgedCoreGraphQLQuery", + }, + } + # Round-trip through the client store so the returned node is typed as the generated + # protocol rather than the bare InfrahubNode the constructor yields. + client.store.set(InfrahubNode(client=client, schema=TRANSFORM_JINJA2_SCHEMA, data=data)) + return client.store.get(kind=CoreTransformJinja2, key=EXISTING_TRANSFORM_ID) + + +def _make_local_transform( + template_path: str = "templates/test.j2", + description: str | None = None, + dependencies: list[str] | None = None, + dependencies_complete: bool = False, +) -> InfrahubRepositoryJinja2: + return InfrahubRepositoryJinja2( + name="test", + repository="repo-id", + query="query-id", + template_path=Path(template_path), + description=description, + dependencies=dependencies if dependencies is not None else [], + dependencies_complete=dependencies_complete, + ) + + +@dataclass +class CompareJinja2Case: + name: str + """Descriptive name for the test scenario (used as test ID).""" + + expected: bool + """Expected result of the comparison: True when the stored and local transform match.""" + + existing_kwargs: dict[str, Any] = field(default_factory=dict) + """Overrides for the stored transform node.""" + + local_kwargs: dict[str, Any] = field(default_factory=dict) + """Overrides for the local transform parsed from the repository config.""" + + +COMPARE_JINJA2_TEST_CASES: list[CompareJinja2Case] = [ + # Identical inputs: the local template_path is a Path while the stored value is a str, so an + # unchanged transform must compare equal across the type boundary rather than look changed. + CompareJinja2Case(name="identical", expected=True), + CompareJinja2Case( + name="template_path_changed", + expected=False, + existing_kwargs={"template_path": "templates/old.j2"}, + local_kwargs={"template_path": "templates/new.j2"}, + ), + CompareJinja2Case( + name="description_changed", + expected=False, + local_kwargs={"description": "New description"}, + ), + CompareJinja2Case( + name="description_removed", + expected=False, + existing_kwargs={"description": "Old description"}, + ), + CompareJinja2Case( + name="description_same", + expected=True, + existing_kwargs={"description": "Same"}, + local_kwargs={"description": "Same"}, + ), + CompareJinja2Case( + name="query_changed", + expected=False, + existing_kwargs={"query_id": "old-query-id"}, + ), + CompareJinja2Case( + name="dependencies_changed", + expected=False, + existing_kwargs={"dependencies": ["templates/test.j2"]}, + local_kwargs={"dependencies": ["templates/test.j2", "templates/partial.j2"]}, + ), + CompareJinja2Case( + name="dependencies_complete_changed", + expected=False, + local_kwargs={"dependencies_complete": True}, + ), +] + + +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in COMPARE_JINJA2_TEST_CASES], +) +async def test_compare_jinja2_transform(test_case: CompareJinja2Case) -> None: + existing = _make_existing_transform(**test_case.existing_kwargs) + local = _make_local_transform(**test_case.local_kwargs) + assert await InfrahubRepositoryIntegrator.compare_jinja2_transform(existing, local) is test_case.expected diff --git a/backend/tests/unit/telemetry/test_utils.py b/backend/tests/unit/telemetry/test_utils.py new file mode 100644 index 00000000000..e6b17fda6a8 --- /dev/null +++ b/backend/tests/unit/telemetry/test_utils.py @@ -0,0 +1,37 @@ +"""Unit tests for the telemetry per-metric graceful-degradation helper. + +The helper runs a single metric coroutine and isolates its failure: a raising +coroutine degrades to ``None`` (interpreted downstream as "source failed"), +while a coroutine that completes returns its value untouched — including a +falsy ``0`` (interpreted as "source succeeded with nothing to count"). +""" + +from __future__ import annotations + +from infrahub.telemetry.utils import safe_metric + + +async def _raises() -> int: + raise RuntimeError("metric source unavailable") + + +async def _returns_zero() -> int: + return 0 + + +async def _returns_value() -> int: + return 42 + + +async def test_raising_coroutine_degrades_to_none() -> None: + assert await safe_metric(_raises()) is None + + +async def test_zero_result_is_preserved() -> None: + result = await safe_metric(_returns_zero()) + assert result == 0 + assert result is not None + + +async def test_non_zero_result_is_preserved() -> None: + assert await safe_metric(_returns_value()) == 42 diff --git a/backend/tests/unit/telemetry/test_workflow.py b/backend/tests/unit/telemetry/test_workflow.py index 015d0e0e913..e8614612667 100644 --- a/backend/tests/unit/telemetry/test_workflow.py +++ b/backend/tests/unit/telemetry/test_workflow.py @@ -7,7 +7,7 @@ import pytest from infrahub.telemetry.repository import TelemetrySnapshotRepository -from infrahub.telemetry.tasks import send_telemetry_push +from infrahub.telemetry.tasks import AnonymousTelemetryGatherer, send_telemetry_push if TYPE_CHECKING: from collections.abc import Iterator @@ -36,12 +36,14 @@ def telemetry_mocks() -> Iterator[dict[str, Any]]: """Combined fixture providing all mocks needed for telemetry workflow tests.""" repo = create_autospec(TelemetrySnapshotRepository, spec_set=True, instance=True) repo.save.return_value = None + gatherer = create_autospec(AnonymousTelemetryGatherer, spec_set=True, instance=True) + gatherer.gather.return_value = _build_telemetry_data_mock() with ( patch( - "infrahub.telemetry.tasks.gather_anonymous_telemetry_data", + "infrahub.telemetry.tasks.build_anonymous_telemetry_gatherer", new_callable=AsyncMock, - return_value=_build_telemetry_data_mock(), - ) as mock_gather, + return_value=gatherer, + ), patch("infrahub.telemetry.tasks.post_telemetry_data", new_callable=AsyncMock) as mock_post, patch("infrahub.telemetry.tasks.get_database", new_callable=AsyncMock, return_value=MagicMock()), patch("infrahub.telemetry.tasks.TelemetrySnapshotRepository", return_value=repo) as mock_repo_cls, @@ -49,7 +51,7 @@ def telemetry_mocks() -> Iterator[dict[str, Any]]: ): mock_registry.id = "dep-123" yield { - "gather": mock_gather, + "gather": gatherer.gather, "post": mock_post, "repo": repo, "repo_cls": mock_repo_cls, diff --git a/backend/tests/unit/test_types.py b/backend/tests/unit/test_types.py index b0ea138a6b6..0a98f15783d 100644 --- a/backend/tests/unit/test_types.py +++ b/backend/tests/unit/test_types.py @@ -3,7 +3,7 @@ from infrahub.core import attribute from infrahub.graphql import types -from infrahub.types import ATTRIBUTE_TYPES +from infrahub.types import ATTRIBUTE_PYTHON_TYPES, ATTRIBUTE_TYPES @pytest.mark.parametrize( @@ -19,7 +19,7 @@ def test_attribute_types_allowed_property_path(test_case: str) -> None: attribute_type = ATTRIBUTE_TYPES[test_case] graphql_query_type = getattr(types, attribute_type.graphql_query) - include_binary_address = test_case in {"IPHost", "IPNetwork"} + include_binary_address = test_case in {"IPAddress", "IPHost", "IPNetwork"} path_list = _get_path_field_list( include_binary_address=include_binary_address, fields=graphql_query_type._meta.fields ) @@ -27,6 +27,11 @@ def test_attribute_types_allowed_property_path(test_case: str) -> None: assert path_list == infrahub_type.get_allowed_property_in_path() +def test_attribute_python_types_cover_every_kind() -> None: + """Every attribute kind needs a python type, the REST schema endpoint looks it up unguarded.""" + assert set(ATTRIBUTE_PYTHON_TYPES) == set(ATTRIBUTE_TYPES) + + def _get_path_field_list(include_binary_address: bool, fields: dict[str, Field]) -> list[str]: """Return list of valid property paths for the specified type.""" excluded_fields = [ diff --git a/changelog/+attribute-kind-change-canonical-values.changed.md b/changelog/+attribute-kind-change-canonical-values.changed.md new file mode 100644 index 00000000000..77dfe589911 --- /dev/null +++ b/changelog/+attribute-kind-change-canonical-values.changed.md @@ -0,0 +1 @@ +Changing an attribute's kind is now rejected when the existing values are not already stored in the canonical form of the new kind. This affects conversions into `IPHost`, `IPNetwork`, `IPAddress` and `MacAddress`. For example converting a `Text` attribute holding `aa-bb-cc-dd-ee-ff` to `MacAddress` is now refused, because the canonical form is `AA:BB:CC:DD:EE:FF`. Normalize the values first, then change the kind. diff --git a/changelog/+common-parent-relationship-filter.fixed.md b/changelog/+common-parent-relationship-filter.fixed.md new file mode 100644 index 00000000000..b5cb214e4b0 --- /dev/null +++ b/changelog/+common-parent-relationship-filter.fixed.md @@ -0,0 +1 @@ +Fixed relationship selectors in object forms not honoring the `common_parent` schema property. The options are now filtered to peers that share the same parent as the value picked for the referenced relationship in the same form, instead of listing every peer. Changing that parent clears a now-invalid selection, and the inline "Add new" form pre-fills the parent when one is already selected so a created peer stays valid. diff --git a/changelog/+infp-234-schema-load-read-only.fixed.md b/changelog/+infp-234-schema-load-read-only.fixed.md deleted file mode 100644 index dba5466ed69..00000000000 --- a/changelog/+infp-234-schema-load-read-only.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The `/api/schema/load` endpoint now tolerates read-only, internal, and unknown fields on a submitted schema (for example a schema read back from Infrahub, or an exported schema), dropping them silently before applying the write contract instead of rejecting the payload. Constrained values and structural requirements are still validated. diff --git a/changelog/+infp-234.changed.md b/changelog/+infp-234.changed.md index 0d469987c6a..36ca0ee7e9d 100644 --- a/changelog/+infp-234.changed.md +++ b/changelog/+infp-234.changed.md @@ -1,7 +1,21 @@ -`POST /api/schema/load` now validates every submitted node, generic, and extension against a user-facing *write* contract. Constrained fields (for example an attribute `kind` or a relationship `cardinality`) set outside their allowed values, out-of-range values, and missing required fields are rejected with a field-level error naming the field and the invalid value. +`POST /api/schema/load` now validates every submitted node, generic, and extension against a user-facing *write* contract, and reports the fields it does not apply instead of ignoring them. -Fields a user may not set — read-only or internal fields such as `inherited`, `used_by`, `hierarchy`, and a derived `kind` on nodes/generics — as well as fields that are genuinely unknown (a typo, or a field removed in a newer version) are dropped silently before the schema is applied, so a schema read back from Infrahub or hand-edited still loads. +Constrained fields (for example an attribute `kind` or a relationship `cardinality`) set outside their allowed values, and out-of-range values, are rejected with a field-level error naming the field and the invalid value: + +```text +nodes[0].relationships[0].cardinality: Input should be 'one' or 'many' (received: 'several') +``` + +Attribute `parameters` belonging to a different attribute `kind` are rejected too — for example `start_range` on a `Number` attribute, which earlier versions accepted and then discarded, so the setting silently had no effect. + +Fields Infrahub computes and owns are accepted and reported as a warning, one per distinct field, naming every kind and element that carried it. These are `inherited`, `used_by`, `hierarchy`, a relationship's `hierarchical`, a node's derived `kind` and `hash`, and the bookkeeping a schema dumped from Infrahub carries on nested blocks such as `parameters`. The submitted value is ignored, so reading a schema back from Infrahub, editing it, and loading it again keeps working: + +```text +'inherited' is a read-only field, the submitted value is ignored [InfraDevice.name, InfraDevice.interfaces] +``` + +`infrahubctl schema load` and `infrahubctl schema check` print these warnings; `infrahubctl validate schema` reports them offline. A field the contract does not recognize at all — a typo, or a field removed in a newer version — is rejected as before, now with the same field-level path. `GET /api/schema` returns the same response body as before and still includes read-only fields such as `inherited` and `used_by`. Its OpenAPI component schemas are now named after the generated read models — `NodeSchemaRead`, `GenericSchemaRead`, `ProfileSchemaRead`, and `TemplateSchemaRead` in place of `APINodeSchema`, `APIGenericSchema`, `APIProfileSchema`, and `APITemplateSchema` — so a client generating types from `openapi.json` needs to update those type names. -The write contract is published as a committed model in the Python SDK (`infrahub_sdk.schema.generated.write`); the SDK `validate_schema()` helper reproduces the server verdict offline so a payload can be checked before submission. +The write contract is published as a committed model in the Python SDK (`infrahub_sdk.schema.generated.write`); the SDK `validate_schema()` helper reproduces the server verdict offline, including the warnings, so a payload can be checked before submission. `client.schema.validate()` reaches the same verdict and raises `ValueError` rather than a pydantic `ValidationError`. diff --git a/changelog/+ipaddress-attribute-kind.added.md b/changelog/+ipaddress-attribute-kind.added.md new file mode 100644 index 00000000000..037bc240a47 --- /dev/null +++ b/changelog/+ipaddress-attribute-kind.added.md @@ -0,0 +1 @@ +Added a new `IPAddress` attribute kind that stores a bare IP address. Unlike `IPHost`, which normalizes `192.0.2.1` to `192.0.2.1/32`, an `IPAddress` value must not carry a prefix length or netmask, and any value that does is rejected. IPv6 values are normalized to their compressed lowercase form. Note that values sort lexically rather than numerically. diff --git a/changelog/+migrate-apollo-to-urql.housekeeping.md b/changelog/+migrate-apollo-to-urql.housekeeping.md new file mode 100644 index 00000000000..be3ff299a9f --- /dev/null +++ b/changelog/+migrate-apollo-to-urql.housekeeping.md @@ -0,0 +1 @@ +Replaced the frontend GraphQL transport (`@apollo/client`) with the lighter `@urql/core`, reducing the JavaScript bundle size. Apollo was used transport-only (no hooks, no cache); all request behavior — auth, request priority, error routing, token refresh, and file uploads — is preserved. No user-facing behavior changes. diff --git a/changelog/+recompute-deleted-read-peer.fixed.md b/changelog/+recompute-deleted-read-peer.fixed.md new file mode 100644 index 00000000000..9dc087b96dc --- /dev/null +++ b/changelog/+recompute-deleted-read-peer.fixed.md @@ -0,0 +1 @@ +Merging or rebasing a branch that deletes a node now refreshes the derived values of the nodes that read the deleted node across a relationship. Their computed attributes, display labels, and human-friendly ids no longer keep naming the deleted node. diff --git a/changelog/+schema-load-unchanged-warnings.fixed.md b/changelog/+schema-load-unchanged-warnings.fixed.md new file mode 100644 index 00000000000..b9c2087e873 --- /dev/null +++ b/changelog/+schema-load-unchanged-warnings.fixed.md @@ -0,0 +1 @@ +`POST /api/schema/load` now returns the warnings it collected even when the submitted schema matches the one already loaded. Previously the response for an unchanged schema omitted them, so a deprecation or read-only-field warning went unreported on every load after the first. diff --git a/changelog/+single-relationship-id-only-shortcut.changed.md b/changelog/+single-relationship-id-only-shortcut.changed.md new file mode 100644 index 00000000000..8c1d812ab57 --- /dev/null +++ b/changelog/+single-relationship-id-only-shortcut.changed.md @@ -0,0 +1 @@ +Improved the performance of GraphQL queries that only request the `id` of a cardinality-one relationship's peer. When no properties, metadata, or additional node fields are requested, the resolver now returns the peer ID already loaded on the parent instead of hydrating a full peer node, reducing database work on relationship-heavy queries. diff --git a/changelog/+telemetry-phase1.added.md b/changelog/+telemetry-phase1.added.md new file mode 100644 index 00000000000..7daf12a42ea --- /dev/null +++ b/changelog/+telemetry-phase1.added.md @@ -0,0 +1,5 @@ +The daily anonymous telemetry payload now reports additional adoption and activity signals. New fields cover active accounts and account groups (`accounts.active`, `accounts.groups`), the count of open non-system branches (`branches.active`), and two branch- and temporal-correct node counts computed the same way the product counts nodes, distinct from the existing raw vertex total: `database.node_count.corenode` (all managed nodes) and `database.node_count.user` (user/business nodes in user-defined namespaces, excluding internal and built-in objects). + +A new `activity_24h` object summarizes what happened over the previous full UTC calendar day: logins and unique logins, validation checks started/passed/failed, artifacts created/updated, branches created/merged/deleted, and webhook deliveries that succeeded or failed. + +All changes are additive and backwards-compatible. Each field is reported independently, so a single failing source yields `null` for that field while the rest of the payload is still gathered and sent; a source that succeeds with nothing to count reports `0`. The `payload_format` identifier is advanced to reflect the new payload version. diff --git a/changelog/3094.fixed.md b/changelog/3094.fixed.md new file mode 100644 index 00000000000..148711953df --- /dev/null +++ b/changelog/3094.fixed.md @@ -0,0 +1 @@ +Fixed issue where Jinja2 Transformations were always marked as being updated during repository imports even though there were no changes. diff --git a/changelog/7836.fixed.md b/changelog/7836.fixed.md new file mode 100644 index 00000000000..18ab3b7650b --- /dev/null +++ b/changelog/7836.fixed.md @@ -0,0 +1 @@ +Fixed node creation failing when a Jinja2 computed attribute formatted a value sourced from a number pool; the computed attribute now renders once the pool value has been allocated. diff --git a/changelog/9915.added.md b/changelog/9915.added.md new file mode 100644 index 00000000000..9e7ea0b86f2 --- /dev/null +++ b/changelog/9915.added.md @@ -0,0 +1 @@ +Added a Sort picker to the proposed changes list to order it by any sortable field, including creation and update dates. The list now defaults to newest created first. diff --git a/changelog/9931.fixed.md b/changelog/9931.fixed.md new file mode 100644 index 00000000000..9301e913e57 --- /dev/null +++ b/changelog/9931.fixed.md @@ -0,0 +1 @@ +Fixed git repository synchronization halting when a branch that had been merged still existed on the remote. diff --git a/dev/README.md b/dev/README.md index dc9887e1965..de649ac77a0 100644 --- a/dev/README.md +++ b/dev/README.md @@ -62,6 +62,7 @@ Backend architecture documentation in [knowledge/backend/](knowledge/backend/): - [async-tasks.md](knowledge/backend/async-tasks.md) - Asynchronous tasks (Prefect) - [message-bus.md](knowledge/backend/message-bus.md) - Message bus system - [api-backpressure.md](knowledge/backend/api-backpressure.md) - Priority-aware load shedding and the database-stress signal +- [telemetry.md](knowledge/backend/telemetry.md) - Anonymous usage telemetry (categories, windowing, retention, degradation) Frontend architecture documentation in [knowledge/frontend/](knowledge/frontend/): diff --git a/dev/adr/0010-generated-user-facing-schema-contract.md b/dev/adr/0010-generated-user-facing-schema-contract.md index 4e8c6c18cbc..d2da83f0833 100644 --- a/dev/adr/0010-generated-user-facing-schema-contract.md +++ b/dev/adr/0010-generated-user-facing-schema-contract.md @@ -34,9 +34,18 @@ and emitted into the Python SDK as committed, shipped artifacts. The server vali through the SDK-hosted write models rather than through a backend-local copy, so one implementation produces both the server's verdict and the client's offline verdict. -Submission **ignores** fields outside the write contract rather than rejecting them: read-only, -internal, and unknown fields are dropped, and only invalid *settable* values are rejected. This -half of the decision reverses the original design, which rejected them. +Submission never applies a field outside the write contract, and reports it according to what it +is. A field the contract knows at that location but the user may not set — a read-only field, the +bookkeeping a schema dumped from the internal models carries, a field belonging to a sibling +variant of a discriminated union — is dropped and reported as a warning. Any other extra field is +rejected, because the only ways to produce one are a typo and a field that no longer exists. +Invalid *settable* values are rejected as before. + +The warning/error split is driven by a generated table of the non-settable fields of each write +class, and applied by walking the submitted payload alongside the validated write document: the +validated document resolves which model applies at each location, including which member of a +discriminated union an attribute matched, so the payload is compared against the fields that +location actually accepts. `invoke backend.validate-generated` regenerates the models and fails on any diff, in the backend tree and inside the SDK submodule, and CI runs it. A unit test asserts that no field classified @@ -66,10 +75,18 @@ How the visibility axis, the generated families, and the load boundary work is d direction. - Changing a schema field spans two repositories: the generated artifact must be regenerated, committed in the submodule, and released in step with the server. -- A mistyped field name is silently dropped instead of reported, so a typo in a schema file fails - quietly rather than loudly. Revisit this if it proves to be a common source of confusion — - reporting unrecognised fields as warnings would preserve the round-trip while restoring the - feedback. +- One payload shape that the last released version accepted now fails: attribute `parameters` + belonging to a different attribute `kind`, which were accepted and then discarded, so the schema + quietly differed from the one the author wrote. The cost of reporting them is that a repository + whose committed schema carries one stops importing until it is corrected. A mistyped field name + was already rejected before this work, since the load endpoint validated through a model with + `extra="forbid"`; only the wording of that rejection changes. +- Extra fields are reported only once the payload validates against the write models, since the + validated document is what resolves the contract applying at each location. A payload rejected + for another reason names its extra fields on the next run rather than in the same response. +- Read-only fields ride the existing `deprecation` warning type rather than a dedicated one, so + that an SDK older than this change can still parse a load response. A dedicated type has to wait + until an SDK tolerant of unknown warning types is the supported floor. ### Neutral @@ -98,7 +115,25 @@ be proven by a test rather than following from construction. The original design — implemented, then reversed in review. Rejecting them broke the read → edit → load path for every client that round-trips a schema, and what it bought was a clearer error for a field the user had not intended to set. An `ignore_extras` opt-in flag was also considered and -rejected: tolerance as the default achieves the same result without a second code path. +rejected: tolerance as the default achieves the same result without a second code path. Splitting +the two cases, as the decision above now does, keeps the round-trip working *and* restores the +error for the fields where an error is the only useful answer. + +### Generate a third model family to classify extra fields + +A `tolerant` variant carrying the read-level field set with `extra="allow"`, validated alongside +the write models, would have let pydantic classify extra fields with no traversal code. Rejected as +disproportionate: it is a second full family of generated models, and a validated write document +plus a generated table of non-settable field names answers the same question. It also risked +extras leaking into the document the server loads. + +### Classify extra fields with a `mode="before"` validator on the write models + +A hook on every generated write class, appending findings to the pydantic validation context, needs +no traversal code and fires even when validation fails elsewhere. Rejected because a before-validator +does not know where it sits in the document, so a finding could name neither the path nor the owning +kind — and reconstructing the parent chain would mean stateful validators pushing and popping +context. ### Filter non-write fields with a hand-written projection step diff --git a/dev/guidelines/backend/python.md b/dev/guidelines/backend/python.md index 389fedea2ab..71df43f9d3c 100644 --- a/dev/guidelines/backend/python.md +++ b/dev/guidelines/backend/python.md @@ -173,6 +173,35 @@ branch_data = {"name": "feature-x", "description": None} branch_data = BranchCreateInput(name="feature-x") ``` +### Use dict.get() for Default Values + +Prefer `dict.get(key, default)` over an existence check or `try/except` when reading a key that may be missing. It is more concise and avoids the cost of raising and catching `KeyError`: + +```python +config: dict[str, int] = {"timeout": 30} + +# ❌ Bad - verbose existence check +if "retries" in config: + retries = config["retries"] +else: + retries = 3 + +# ❌ Bad - exception handling for an expected-missing key +try: + retries = config["retries"] +except KeyError: + retries = 3 + +# ✅ Good - get() with an explicit default +retries = config.get("retries", 3) +``` + +Guidelines: + +- `get()` without a second argument returns `None` for missing keys. +- Chain for nested access: `config.get("db", {}).get("host", "localhost")`. +- Use `setdefault()` when the default is a mutable object you intend to build up: `cache.setdefault("results", []).append(42)`. + ## Docstrings (Google-style) All public functions and classes must have Google-style docstrings: diff --git a/dev/guidelines/frontend/route-architecture.md b/dev/guidelines/frontend/route-architecture.md index 7a8b2390fee..7a578a2b380 100644 --- a/dev/guidelines/frontend/route-architecture.md +++ b/dev/guidelines/frontend/route-architecture.md @@ -310,8 +310,8 @@ export function DatePreferencesProvider({ children }) { Why this matters, and why **`RequireAuth` is not the gate**: `RequireAuth` renders its children when `isAuthenticated || config.main.allow_anonymous_access`, so with anonymous access enabled the whole authenticated route tree — and any provider mounted in it — renders for **logged-out** users. An -ungated authenticated query then 401s, Apollo's `errorLink` calls `redirectToLogin`, and the user is -bounced to `/login`. Because it depends on timing/anonymous-access it surfaces as a **flaky E2E +ungated authenticated query then 401s, the transport's error routing calls `redirectToLogin`, and the +user is bounced to `/login`. Because it depends on timing/anonymous-access it surfaces as a **flaky E2E failure** (auth-setup timeout, or "not logged in" specs), not an obvious error. Prefer the mount gate above over react-query `enabled: isAuthenticated` — it keeps the query hook diff --git a/dev/guides/backend/creating-async-tasks.md b/dev/guides/backend/creating-async-tasks.md index 87cf0164a50..e825a419233 100644 --- a/dev/guides/backend/creating-async-tasks.md +++ b/dev/guides/backend/creating-async-tasks.md @@ -88,6 +88,15 @@ The `flow_run_name` is visible to users in the Infrahub UI. Keep it clear and sh @flow(name="branch-merge", flow_run_name="Merge branch {branch} on {branch_id}") ``` +### Logging inside flows, tasks, and their helpers + +- Inside a `@flow` or `@task` body, use Prefect's `get_run_logger()`. +- In a plain helper called from within a flow (no run context, also called from tests), use + `infrahub.log.get_run_logger()` — the `infrahub.tasks` logger. + +A bare `logging.getLogger(__name__)` will not surface in Prefect. See the Logging section of +`dev/knowledge/backend/async-tasks.md` for why. + ### Step 3: Register the WorkflowDefinition Add your workflow to `backend/infrahub/workflows/catalogue.py`: @@ -264,7 +273,7 @@ Before submitting your workflow: - [ ] Workflow added to `WORKFLOWS` list - [ ] Correct `WorkflowType` selected (CORE/USER/INTERNAL) - [ ] `DATABASE_CHANGE` tag added if workflow modifies database -- [ ] Uses `get_run_logger()` for logging +- [ ] Logs use a Prefect-visible logger (`get_run_logger()` in flows/tasks; `infrahub.log.get_run_logger()` in helpers), never a bare module logger - [ ] Flow body is a thin composition root — singletons resolved at the flow entry, logic in a dependency-injected component - [ ] Any other code that needs the workflow's name imports its `WorkflowDefinition` from `catalogue.py` instead of re-typing the string - [ ] Tests cover workflow execution (using local execution mode) diff --git a/dev/knowledge/backend/async-tasks.md b/dev/knowledge/backend/async-tasks.md index bd32f518b98..8404ea40b5f 100644 --- a/dev/knowledge/backend/async-tasks.md +++ b/dev/knowledge/backend/async-tasks.md @@ -228,6 +228,13 @@ Available dependencies: - `get_event_service()`: Event emission service - `get_component()`: Component registry access +## Logging + +Prefect only surfaces logs from its own run logger plus the loggers named in the worker's task-logger set — `DEFAULT_TASK_LOGGERS = ["infrahub.tasks"]` in `backend/infrahub/workers/infrahub_async.py`, extended by `config.SETTINGS.workflow.extra_loggers`. A bare `logging.getLogger(__name__)` sits outside that set, so its records never reach the task manager. + +- **Inside a `@flow` or `@task` body**, use Prefect's `get_run_logger()`. +- **In a plain helper** that runs inside a flow but is not itself decorated — so it has no Prefect run context, and is typically also called directly from tests — use `infrahub.log.get_run_logger()`. It returns the `infrahub.tasks` stdlib logger, which the worker registers with Prefect and which is safe to call with no run context (Prefect's own `get_run_logger()` raises outside a run). + ## Read Query Optimization in Prefect Tasks When a flow only needs a few fields from a node (e.g. `id`, `name`, `status`), avoid `client.all()`, `client.filters()`, or `client.get(prefetch_relationships=True)` — they fetch the full object graph. Use a targeted `execute_graphql()` call instead. diff --git a/dev/knowledge/backend/display-labels-and-hfid.md b/dev/knowledge/backend/display-labels-and-hfid.md index 743fbb4898e..62c597306a6 100644 --- a/dev/knowledge/backend/display-labels-and-hfid.md +++ b/dev/knowledge/backend/display-labels-and-hfid.md @@ -147,10 +147,15 @@ For attribute kinds whose accepted input form differs from their normalized stor |------|-----------------| | `IPHost` | `ipaddress.ip_interface(value).with_prefixlen` (e.g. `192.0.2.1` → `192.0.2.1/32`) | | `IPNetwork` | `ipaddress.ip_network(value).with_prefixlen` (e.g. `2001:db8:0:0::/32` → `2001:db8::/32`) | +| `IPAddress` | `str(ipaddress.ip_address(value))` (e.g. `2001:0DB8::0001` → `2001:db8::1`); a prefix or netmask is rejected outright rather than normalized | | `MacAddress` | `netaddr.EUI(addr=value).format(dialect=netaddr.mac_unix_expanded).upper()` (e.g. `aa-bb-cc-dd-ee-ff` → `AA:BB:CC:DD:EE:FF`) | `_normalize_value()` is intentionally a separate hook from `serialize_value()`. The latter is also used by `HashedPassword` (destructive hash), `ListAttribute`/`JSONAttribute` (type-changing JSON dump), and the base class (Enum unwrap) — transforms that cannot run on `attr.value` itself. For kinds that need input-time normalization, `serialize_value()` delegates to `_normalize_value(self.value)` so the normalized form has a single source of truth per class. When adding a new kind that needs input-time normalization, override `_normalize_value()` (not `serialize_value`). +`_normalize_value()` is a `classmethod` so a value can be checked without building an attribute instance. `AttributeKindUpdateValidatorQuery` relies on that to enforce canonicality when an attribute's kind changes: a kind change runs no data migration over the stored values, so a value that parses under the new kind but is not already in its canonical form would survive un-rewritten and then miss every `__value` filter and uniqueness comparison. The check is unconditional — kinds that do not normalize inherit the identity `_normalize_value()`, so it is a no-op for them and a newly added normalizing kind is covered without touching the validator. + +The practical consequence is that a kind change into a normalizing kind is only allowed when the existing values are already canonical. Converting `Text` → `MacAddress` over `aa-bb-cc-dd-ee-ff` is refused, and `IPAddress` ↔ `IPHost` is refused in both directions because neither side's stored form is canonical for the other. + ## Hierarchical Relationships and Inline Fragments diff --git a/dev/knowledge/backend/query-pattern.md b/dev/knowledge/backend/query-pattern.md index 210fbc34b56..c5ffd0443fe 100644 --- a/dev/knowledge/backend/query-pattern.md +++ b/dev/knowledge/backend/query-pattern.md @@ -4,6 +4,31 @@ All database access in Infrahub goes through Query classes that encapsulate Cypher queries with proper parameterization, branch-awareness, and temporal versioning. Queries return typed dataclass results for type safety and clear API contracts. +## Accessing schema: inject `SchemaManager`, else `db.schema`, never `registry` + +The `registry` is a legitimate in-memory cache (schemas, branches, node classes) but a global singleton imported across ~150 modules — a source of circular imports and coupling. Encapsulate and inject it instead. Preference order: + +1. **Inject `SchemaManager`** into the constructor, built at the entry point (task/flow/API/CLI) — never import `registry` inside a component. +2. **`db.schema`** when injection isn't practical — temporally correct against the operation's branch/time; `registry.schema` is not. +3. **`registry.schema`** — avoid. + +```python +# ✅ Best - inject SchemaManager, constructed at the entry point +class MyComponent: + def __init__(self, schema_manager: SchemaManager) -> None: + self.schema_manager = schema_manager + +# ✅ OK - db.schema when injection isn't practical (temporally correct) +schema = db.schema.get(name="MyNode", branch=branch) + +# ❌ Avoid - global singleton, loses temporal flexibility +schema = registry.schema.get(name="MyNode") +``` + +Components already accept `schema_manager` (the merge orchestrator, diff calculator, schema update coordinator, …); entry points still pass `registry.schema`, concentrating the access at the boundary. The next step is an accessor like the existing `get_database()` / `get_component()` so entry points can drop `registry` entirely. + +Exception: `registry` stays for hot (per-request) in-memory reads where a DB round-trip is a real regression (e.g. `registry.branch`); cold paths (daily tasks) use the DB. New-code preference — don't sweep existing call sites. + ## Query Lifecycle ### Initialization @@ -146,6 +171,52 @@ class MyQuery(Query): self.add_to_query("RETURN n.uuid AS uuid, n.name AS name LIMIT 100") # Manual pagination ``` +#### Paginating a query that expands each row + +The automatic clause is appended *after* the `RETURN`, so it bounds the rows a query returns, not the work it does to produce them. When a query expands each matched row — a per-row `CALL` subquery, a `collect()` over a traversal — that expansion has already run for every match by the time the automatic `LIMIT` applies, and an `ORDER BY` there forces every expanded row to be materialized before sorting. A query that must bound *that* work takes its page in the body, before the expansion: + +```python +class PagedNodeFieldsQuery(Query): + name = "paged_node_fields" + type = QueryType.READ + insert_limit = False + + def __init__(self, limit: int, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.limit = limit + + async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: + self.params = {"page_offset": self.offset or 0, "page_limit": self.limit} + self.add_to_query(""" + MATCH (n:Node) + WITH n + ORDER BY n.uuid, elementId(n) + SKIP $page_offset + LIMIT $page_limit + CALL (n) { + OPTIONAL MATCH (n)-[:HAS_ATTRIBUTE]->(a:Attribute) + RETURN collect(DISTINCT a.name) AS attr_names + } + """) + self.return_labels = ["n.uuid AS node_uuid", "attr_names"] +``` + +Three details make this correct: + +- Type the page size as `limit: int`, not `int | None`: a query that pages itself has no meaningful unpaged mode, and a required constructor parameter says so at the call site instead of failing later. +- Read the bounds from the base class's `self.limit` and `self.offset` rather than adding parallel attributes, and bind them as query parameters. Reusing the base fields keeps one source of truth for the page size and is what `execute()` inspects (see below); parameters rather than interpolated literals let every page reuse one compiled plan. +- Order strictly. `SKIP`/`LIMIT` over an unordered match can return one row on two pages and another on none; `elementId(n)` breaks ties when the sort property is not unique. + +`GetPathDetailsBranchQuery` in `backend/infrahub/core/migrations/query/path_details.py` is the reference implementation, driven by the caller loop in `backend/infrahub/core/migrations/helpers/attribute_recompute.py`. + +#### Always set self.limit on a self-paging read + +`Query.execute()` treats a READ with neither `limit` nor `offset` as unpaginated and routes it through `query_with_size_limit()`, which re-runs the query once per `database.query_size_limit` rows with a growing `SKIP` appended after the `RETURN`. Wrapped around a query that already pages itself, that costs one extra execution of the whole query per full page; the extra run's rows are all discarded by the outer `SKIP`, so results stay correct and only the cost shows up. + +With `insert_limit = False` it is worse than wasteful. The wrapper cannot append its `SKIP`/`LIMIT`, so every iteration re-sends identical text, and the loop ends only because a batch came back shorter than `query_size_limit`. A query whose own bound is greater than or equal to `query_size_limit` never produces a short batch, so the loop never terminates and keeps appending the same rows. + +Because the wasted execution changes no returned value, assertions on query results cannot detect it. `CountingInfrahubDatabase` in `backend/tests/helpers/db_query_counter.py` counts executions by query name, so a test can assert how many queries a paged read issues. + ### Branch-Aware Edge Resolution Every edge in the graph has branch/temporal properties (`branch`, `branch_level`, `from`, `to`, `status`). When traversing multiple edges in a single query, filter each edge independently to resolve the correct active version: @@ -485,12 +556,7 @@ Specialized base classes for different domains: **Database Object in Initialization:** The `InfrahubDatabase` object is passed during initialization for: -1. **Schema access via database proxy:** Enables temporal queries using previous schema versions - - ```python - schema = db.schema.get(name="MyNode", branch=branch) # Good - schema = registry.schema.get(name="MyNode") # Avoid: loses temporal flexibility - ``` +1. **Schema access via database proxy:** Enables temporal queries using previous schema versions — see [Accessing schema: inject `SchemaManager`, else `db.schema`, never `registry`](#accessing-schema-inject-schemamanager-else-dbschema-never-registry). 2. **Database type abstraction:** Contains database-specific functions diff --git a/dev/knowledge/backend/schema-definitions.md b/dev/knowledge/backend/schema-definitions.md index 68a7efe126d..591187e3fb4 100644 --- a/dev/knowledge/backend/schema-definitions.md +++ b/dev/knowledge/backend/schema-definitions.md @@ -114,9 +114,9 @@ renders two model families from them by filtering each field on its visibility l - **write models** — include only `WRITE` fields and set `extra="ignore"`, so a read-only, internal, or unknown field in a submitted payload is dropped by pydantic itself instead of - rejected. That holds at every nesting level, and the per-kind discriminated unions - (attribute kinds, computed-attribute kinds) resolve first, so each variant keeps only the - fields valid for it. Constrained values that *are* settable are still validated and + reaching the loaded schema. That holds at every nesting level, and the per-kind discriminated + unions (attribute kinds, computed-attribute kinds) resolve first, so each variant keeps only + the fields valid for it. Constrained values that *are* settable are still validated and rejected when out of range. No hand-written filtering step is needed at the boundary. - **read models** — include `WRITE` and `READ` fields, describing the shape returned by `GET /api/schema`. @@ -124,6 +124,34 @@ renders two model families from them by filtering each field on its visibility l Because both families are generated from the same definitions, a field's classification is declared once and both the write contract and the read shape follow automatically. +### Reporting the fields a payload sets but the contract drops + +`extra="ignore"` decides that a non-write field has no effect; it does not decide whether the +user hears about it. That split is driven by a third generated artifact, +`python_sdk/infrahub_sdk/schema/generated/contract.py`, holding the non-settable field names of +each write class: + +- a name **in** the table is dropped and reported as a **warning** — a `READ`-level field, the + `id`/`state` bookkeeping every internal schema model carries on the nested value models + (parameters, choices, computed attribute, extensions), or a field belonging to a sibling + variant of a discriminated union. These are all names a schema read back from Infrahub can + carry, so accepting them is what keeps the read → edit → load round trip working. +- a name **not** in the table is an **error**, since the only ways to produce one are a typo and + a field that no longer exists. + +The table is built by `SdkSchemaGenerator._read_only_fields` from two diffs: the read variant of +a class against its write variant, and the internal pydantic counterpart of a value model against +its generated write model. Root-level keys that `GET /api/schema` adds over the write root +(`main`, `profiles`, `templates`, `namespaces`) are listed explicitly and pinned against +`SchemaReadAPI` by a test. + +Applying the table means knowing which model governs each place in the payload. +`_collect_extra_fields` in `validate.py` walks the submitted payload alongside the *validated* +write document: the validated document resolves the model at every location — including which +union member an attribute matched — so the raw keys are compared against the fields that location +actually accepts. One consequence: extra fields are reported only once the payload validates, +so a payload rejected for another reason names them on the next run. + A field whose valid values are a closed set is generated as a dedicated `(str, Enum)` class in `python_sdk/infrahub_sdk/schema/generated/enums.py` and referenced by both families, rather than as a bare `str` or an inline `Literal`. The allowed values therefore travel with the model, so a @@ -160,6 +188,7 @@ change; CI fails if the generated artifact is stale. | `Visibility` enum | `backend/infrahub/core/constants/schema.py` | | SDK write/read generator | `SdkSchemaGenerator` in `tasks/backend.py` | | Generated SDK write/read models (do not edit) | `python_sdk/infrahub_sdk/schema/generated/{write,read}.py` | +| Generated non-settable field table (do not edit) | `python_sdk/infrahub_sdk/schema/generated/contract.py` | | Offline write-contract validator | `python_sdk/infrahub_sdk/schema/validate.py` | | RelationshipSchema class | `backend/infrahub/core/schema/relationship_schema.py` | | AttributeSchema class | `backend/infrahub/core/schema/attribute_schema.py` | @@ -171,4 +200,4 @@ change; CI fails if the generated artifact is stale. - [Code Generation](code-generation.md) — How schema definitions become generated code - [Database Schema](database-schema.md) — How schemas map to Neo4j graph structure - [ADR 0010](../../adr/0010-generated-user-facing-schema-contract.md) — Why the user-facing - contract is generated into the SDK, and why submission ignores non-write fields + contract is generated into the SDK, and how submission reports the fields it does not apply diff --git a/dev/knowledge/backend/telemetry.md b/dev/knowledge/backend/telemetry.md new file mode 100644 index 00000000000..2191118c623 --- /dev/null +++ b/dev/knowledge/backend/telemetry.md @@ -0,0 +1,124 @@ +# Telemetry + +> Part of: `dev/knowledge/backend/` | Related: [Events System](events.md), [Asynchronous Tasks](async-tasks.md) + +Infrahub gathers an anonymous usage snapshot once a day. The snapshot is always stored locally +(so air-gapped and opted-out deployments still retain their own history) and, unless the +operator opts out, is also sent to the OpsMill telemetry endpoint. It exists to understand +adoption and scale, never to capture customer data. + +## Collection flow + +A daily Prefect flow (`anonymous_telemetry_send`, cron ~02:00 UTC with a per-deployment random +minute) gathers the payload, stores it as a `TelemetrySnapshot`, then conditionally sends it: + +```text +anonymous_telemetry_send (daily) + └─ build_anonymous_telemetry_gatherer() → AnonymousTelemetryGatherer.gather() → TelemetryData + └─ TelemetrySnapshot.save() ← ALWAYS stored locally first + └─ opted out? → mark "skipped" + opted in? → POST to endpoint → mark "sent" / "failed" +``` + +The random cron minute spreads load across deployments; it is why the windowing below is +anchored to a calendar boundary rather than to the moment the flow happens to run. + +## What is collected — by category + +The payload groups metrics into categories. Fields are documented in the payload contract; the +distinction that matters operationally is each category's **temporal model** (below). + +| Category | What it covers | +|----------|----------------| +| Deployment | anonymous deployment id, Infrahub version/type, Python/platform | +| Workers | worker pool size and active count | +| Branches | total and open (non-system) branch counts | +| Accounts | active accounts, account groups | +| Schema | node/generic kind counts, last schema change | +| Features | how many objects of adoption-signalling kinds exist (artifacts, repos, generators, …) | +| Database | database type, node/relationship counts, server + host system info | +| Prefect | event tally, automation counts, work-pool state | +| Activity (24h) | logins, checks, artifacts, branch actions, webhook deliveries | + +### Activity (24h) field semantics + +| Field | Counts | +|-------|--------| +| `logins` / `unique_logins` | Interactive sign-ins only — password, OIDC, OAuth2. Per-request API-key/token authentication is stateless and never emits a login event, so token-authenticated SDK/CI traffic is **not** included. | +| `checks_started` / `_passed` / `_failed` | Validator lifecycle events (see the checks caveat under Graceful degradation). | +| `artifacts_created` / `_updated` | Artifact lifecycle events. | +| `branches_created` / `_merged` / `_deleted` | Branch lifecycle events. | +| `webhooks_fired_success` / `_failure` | Terminal `webhook-process` flow-run states. | + +## Temporal models (the important part) + +Not every number means the same thing over time. There are three kinds: + +1. **Point-in-time snapshot** — most metrics (node/relationship counts, accounts, branches, + schema, features, workers, database info) are the *current* value at gather time. Re-running + the flow reflects the graph as it is now. + +2. **Cumulative over Prefect retention (~7 days)** — the `prefect.events` tally is a raw count + of each event type that Prefect *still retains*. Prefect expires events after ~7 days, so + this is a rolling window bounded by retention — **not** a per-day figure and not comparable + day to day. This is the older, coarse signal. + +3. **Windowed — previous full UTC day** — every `activity_24h.*` metric counts only events (or + webhook flow-runs) that occurred within `[yesterday 00:00 UTC, today 00:00 UTC)`. This is the + precise daily signal that supersedes the coarse cumulative tally for activity. + +The contrast between (2) and (3) is deliberate: `prefect.events` answers "roughly how much of X +is Prefect holding right now", while `activity_24h` answers "exactly how much X happened +yesterday". + +## Windowing + +`get_activity_window()` returns the half-open interval `[start, end)` where `end` is midnight +UTC of the current day and `start` is 24h earlier — the previous full calendar day. Because it +is anchored to the midnight boundary (not to `now`), consecutive daily runs tile exactly with no +overlap or gap regardless of the jittered cron minute. The upper bound is exclusive; the event +counters pull their query's inclusive `until` back one microsecond so an event stamped exactly on +midnight lands in one window only, never two. + +### Retention interaction + +Prefect keeps events and flow-runs for ~7 days. The windowed metrics only ever look one day back, +so they are safe as long as the daily flow runs within retention (it runs every day, well inside +7 days). If the flow were down for several days, days beyond retention could not be recovered — +the metrics are a live daily sample, not a backfillable ledger. + +## Graceful degradation + +Every metric source is gathered through a single helper (`safe_metric`) that isolates failures: +if a source raises, that field is reported as `null` (and the failure is logged) while the rest +of the payload is still built, stored, and sent. A source that succeeds with nothing to count +reports `0`. So **`null` means "could not measure", `0` means "measured, nothing there"**. + +One caveat on the check metrics: `checks_started` counts every validator that starts, but +`checks_passed`/`checks_failed` are only emitted for validators that run through the checks +runner. A validator that concludes without executing checks (a trivial no-op) is counted in +`checks_started` only, so `started` can exceed `passed + failed` without any run being +incomplete. + +## Storage & access + +Snapshots are stored as `TelemetrySnapshot` nodes with a `remote_send_status` +(`pending`/`sent`/`skipped`/`failed`). They are readable regardless of opt-out via +`infrahubctl telemetry list` / `infrahubctl telemetry export` or `GET /api/telemetry/snapshots`, +both gated on the `READ_TELEMETRY` global permission. + +## Key Locations + +| Path | Purpose | +|------|---------| +| `backend/infrahub/telemetry/tasks.py` | Daily flow, payload assembly, remote send | +| `backend/infrahub/telemetry/task_manager.py` | Windowed event / webhook-run counters | +| `backend/infrahub/telemetry/utils.py` | Degradation helper, 24h window functions, infrahub-type detection | +| `backend/infrahub/telemetry/database.py` | Database and node-count metrics | +| `backend/infrahub/telemetry/models.py` | Payload schema | +| `backend/infrahub/workflows/catalogue.py` | Registers the `anonymous_telemetry_send` deployment | + +## See Also + +- [Events System](events.md) — the Prefect events the activity metrics count +- [Asynchronous Tasks](async-tasks.md) — how the daily flow is scheduled and run diff --git a/dev/knowledge/frontend/auth-methods.md b/dev/knowledge/frontend/auth-methods.md index c9559eb2d09..6086f662d37 100644 --- a/dev/knowledge/frontend/auth-methods.md +++ b/dev/knowledge/frontend/auth-methods.md @@ -43,7 +43,9 @@ authentication/ Import direction is the entity rule: `ui/ → domain/ → api/`. SSO has no api/domain/queries (it's a redirect link list, not a fetch). Storage keys live with their consumers: token keys in `api/token-storage.ts`, `LAST_USED_METHOD_KEY` in `ui/hooks/use-last-used-method.ts`. -> `shared/` transport (`api/rest/client`, `api/graphql/graphqlClientApollo`, graphiql fetcher) imports this entity's token surface (`api/token-storage`, `domain/use-cases/redirect-to-login`) — the one sanctioned `shared → entity` transport edge (auth is cross-cutting). `api/rest/client` and `api/graphql/graphqlClientApollo` also import `ui/queries/refresh-access-token.query`, sharing one TanStack query so concurrent 401s trigger a single refresh. +> `shared/` transport (`api/rest/client`, `api/graphql/client`, graphiql fetcher) imports this entity's token surface (`api/token-storage`, `domain/use-cases/redirect-to-login`) — the one sanctioned `shared → entity` transport edge (auth is cross-cutting). `api/rest/client` and `api/graphql/client` also import `ui/queries/refresh-access-token.query`, sharing one TanStack query so concurrent 401s trigger a single refresh. +> +> On the GraphQL side the refresh loop is `@urql/exchange-auth`: `didAuthError` matches the `TOKEN_EXPIRED` catalogue code, `refreshAuth` awaits that shared TanStack query, and the exchange pauses and replays the held operations itself — one refresh across concurrent operations, then a single replay. A refresh that throws, or an expiry that persists after the replay, redirects to `/login`. ## The registry diff --git a/dev/knowledge/frontend/date-rendering.md b/dev/knowledge/frontend/date-rendering.md index a1a878c3c35..953bf69df70 100644 --- a/dev/knowledge/frontend/date-rendering.md +++ b/dev/knowledge/frontend/date-rendering.md @@ -26,8 +26,8 @@ and never a hardcoded pattern. - `DatePreferencesProvider` (`entities/preferences/ui/date-preferences-provider.tsx`) fills the context from `useGetEffectivePreferences()`, but **only once the user is authenticated** — it returns its children unchanged when logged out. The query needs auth, yet the app also renders for - logged-out users (`allow_anonymous_access`), so an ungated fetch would 401 and Apollo's error link - would bounce them to `/login`. Gating by *mount* (not react-query `enabled`) keeps the query hook + logged-out users (`allow_anonymous_access`), so an ungated fetch would 401 and the transport's + error routing would bounce them to `/login`. Gating by *mount* (not react-query `enabled`) keeps the query hook auth-agnostic. `RequireAuth` is **not** a sufficient gate here — it renders for anonymous users too. - When no provider is mounted, or a preference's `source` is `"DEFAULT"` (nothing set), formatting falls back to the **browser locale + zone** (`toLocaleString`) — never a hardcoded pattern. So diff --git a/dev/knowledge/frontend/entities-structure.md b/dev/knowledge/frontend/entities-structure.md index faf1dd489c2..e273b72a4d1 100644 --- a/dev/knowledge/frontend/entities-structure.md +++ b/dev/knowledge/frontend/entities-structure.md @@ -287,10 +287,7 @@ Do not write a one-off `resolveUuid` function. ### api-layer `graphqlClient` call conventions -Two defaults of the shared `graphqlClient` are easy to override by mistake in `api/*-from-api.ts` files: - -- **Don't pass `fetchPolicy`.** The client already defaults to `no-cache` (TanStack Query owns caching, not Apollo). Passing `fetchPolicy: "no-cache"` is redundant — omit it. -- **Mutations already surface their own error toast.** The client's error link shows a toast for a failed request. If the caller *also* renders one (e.g. in a `useMutation` `onError`), the user sees two. To let the caller own the toast, suppress the client's with the mutation `context`: +- **Mutations already surface their own error toast.** The client routes a failed request to a toast. If the caller *also* renders one (e.g. in a `useMutation` `onError`), the user sees two. To let the caller own the toast, suppress the client's with the mutation `context`: ```ts graphqlClient.mutate({ @@ -362,10 +359,10 @@ See `dev/guidelines/frontend/naming-conventions.md` for the full naming conventi ## GraphQL transport vs server-state hooks -Apollo Client is kept as the GraphQL transport (auth links, error handling, retry) only. All server-state hooks are TanStack Query. Do not use `useQuery` / `useMutation` / `useLazyQuery` from `@apollo/client` — they were removed in 2026-05. +`@urql/core` is the GraphQL transport (auth, error routing, token refresh, uploads) only — there is no GraphQL-layer cache and there are no GraphQL hooks. All server-state hooks are TanStack Query. -- `@apollo/client` imports are allowed **only** in `src/app/app.tsx` (for `ApolloProvider`) and `src/shared/api/graphql/graphqlClientApollo.tsx` (client construction), plus `gql` template-tag imports in `entities/*/api/` files. -- React hooks (`useQuery`, `useMutation`, etc.) from `@apollo/client` are forbidden throughout the codebase. +- `@urql/core` imports are allowed **only** in `src/shared/api/graphql/client.ts` (tests may import it to build documents). There is no provider to wrap the app in — the client is used imperatively. +- Everything else imports `graphql` and `graphqlClient` from `@/shared/api/graphql/client`, so the transport library stays swappable. `graphql()` covers both cases: a template literal gives a typed document, and a runtime-assembled string (the `jsonToGraphQLQuery` sites) gives an untyped one. - Use `useQuery` / `useMutation` from `@tanstack/react-query` (typically via the pattern in `ui/queries/`) for all data fetching. ### Mutation invalidation diff --git a/dev/knowledge/frontend/request-priority.md b/dev/knowledge/frontend/request-priority.md index 6ab46c2301f..373de2024ec 100644 --- a/dev/knowledge/frontend/request-priority.md +++ b/dev/knowledge/frontend/request-priority.md @@ -21,16 +21,17 @@ trusted, and why the header is stamped at the transport boundary instead of at c - `PRIORITY_HEADER = 'X-Priority'`. - `resolvePriority(value)` — normalizes an untyped per-request value: returns `'low'` only for exactly `'low'`, everything else (`'medium'`, `undefined`, garbage) → `'high'`. - Each transport runs its value through this before writing the header, so a stray or - legacy value cannot leak an out-of-contract priority. + Any transport that accepts a per-request priority runs its value through this before + writing the header, so a stray or legacy value cannot leak an out-of-contract priority. + A transport that only ever emits the default writes `DEFAULT_PRIORITY` directly. ## Four injection points (default `high`) The header is stamped at each transport entry, not at call sites: -1. **Apollo GraphQL** — `priorityLink` (`setContext`) in - `shared/api/graphql/graphqlClientApollo.tsx`, inserted into the link chain. Uploads - ride the same terminating `createUploadLink`, so they inherit it for free. +1. **GraphQL (urql)** — the `fetchOptions` handed to each `Client` in + `shared/api/graphql/client.ts`, so every operation on that client carries it. Uploads ride + the same terminating `fetchExchange`, so they inherit it for free. 2. **REST (`openapi-fetch`)** — `authMiddleware.onRequest` in `shared/api/rest/client.ts`. The header is set before the `Request` clone captured for 401 replay, so replay preserves it. @@ -40,22 +41,19 @@ The header is stamped at each transport entry, not at call sites: 4. **GraphiQL fetcher** — `shared/libs/graphiql/use-graphiql-fetcher.ts` sets `X-Priority: high` on its raw sandbox fetch. -The header survives both 401-refresh replay paths (Apollo `...oldHeaders` spread; REST -stored clone) and the file-upload rebuild path. +The header survives both 401-refresh replay paths (urql's `authExchange` replays the operation +with its context, including `fetchOptions`; REST replays a stored clone) and the file-upload +rebuild path. ## Opting a request down to `low` (one convention per transport) The default is `high`; an undeclared request needs no change. To demote a single request, declare it at the call site using its transport's idiom: -- **GraphQL** — `context: { priority: 'low' }` on the operation. - **REST** — pre-set the header via `params: { header: { 'X-Priority': 'low' } }` (openapi-fetch's `options` is read-only and exposes no custom field, so the header itself is the opt-in surface). -- **Raw fetch** — the `{ priority: 'low' }` option argument to `fetchUrl`. - -No helper wraps these: the v1 `low` set is empty (no production caller demotes yet), so a -helper would serve only tests (YAGNI). The mechanism plus convention is the deliverable. +- **GraphQL** has no per-operation opt-down. ## Watched status stays `high` diff --git a/dev/specs/ifc-2704-incremental-merge-regen/performance-scenarios.md b/dev/specs/ifc-2704-incremental-merge-regen/performance-scenarios.md index bfa39fa6362..1981afe78f8 100644 --- a/dev/specs/ifc-2704-incremental-merge-regen/performance-scenarios.md +++ b/dev/specs/ifc-2704-incremental-merge-regen/performance-scenarios.md @@ -122,3 +122,6 @@ choice is correctness over precision. | Limitation | Evidence | |------------|----------| | A composite artifact that inlines an upstream artifact's content (content composition) is not refreshed after a direct merge that changes the upstream. The composite keeps stale inlined content. | `integration_docker/test_merge_composition_cascade.py::TestMergeCompositionCascade::test_direct_merge_refreshes_inlined_section` (asserts the fresh content, so it fails while the gap is unfixed; local-only, so it does not gate CI); confirmed live on the demo stack | +| Narrowing reduces to the impacted members only when the changed field is a root attribute of the query's target object, or a group-membership change. When the changed data is reached through a relationship (a traversed kind in the query), or the query does not resolve to a single object per root, the resolver cannot map the change back to a member and widens to the whole target group. The behavior is safe (over-execution only), but it negates the per-member benefit for the common case, since most artifacts and generators read relationship data (a device's interfaces, IP addresses, BGP sessions). | [IFC-2946](https://opsmill.atlassian.net/browse/IFC-2946); `QueryImpactClassifier._must_widen` (`backend/infrahub/core/regeneration/impact_classifier.py`) widens on a non-unique-target query or a change on a `traversed_kinds` kind; validated live on release-1.11 | +| The merge diff summary is serialized into a single cached value with no size ceiling. A large merge can produce a summary that exceeds the cache backend's per-value limit (NATS JetStream rejects above ~1 MB; Redis accepts up to 512 MB with memory and latency pressure), so an oversized write fails on NATS and falls back to full regeneration, while on Redis it succeeds under pressure. | [IFC-2943](https://opsmill.atlassian.net/browse/IFC-2943); measured on the live serializer at ~803 bytes per changed node, so ~1,245 changed nodes reach 1 MB, with serialization staying cheap (~3.5 ms at 1 MB), so the binding constraint is the backend value-size limit, not CPU | +| Every merge that runs selective regeneration persists a diff root to capture the post-generator output, then reads it once and discards it without removing it, so each such merge leaves one orphaned diff root in the graph; the rebase path leaks the same way. | [IFC-2941](https://opsmill.atlassian.net/browse/IFC-2941); `GeneratorTrackingGroupDiffCapturer` (`backend/infrahub/core/merge/selective_regen/generator_diff_capturer.py`) saves via `create_or_update_arbitrary_timeframe_diff` under a bare UUID name, so the root carries no tracking id to supersede or wipe | diff --git a/dev/specs/ifc-2844-definition-fingerprint/spec.md b/dev/specs/ifc-2844-definition-fingerprint/spec.md index d1ea2c171e2..53b42cf2c8c 100644 --- a/dev/specs/ifc-2844-definition-fingerprint/spec.md +++ b/dev/specs/ifc-2844-definition-fingerprint/spec.md @@ -153,9 +153,16 @@ The `watch:` declaration in `.infrahub.yml` controls how far the fingerprint can #### Watch semantics - **FR-016**: When `watch` is not declared (`None`), the system MUST fold the current commit id into the fingerprint, so the fingerprint changes on every commit (the safe default that reverts to legacy over-regeneration). + + > **Amendment (IFC-2952, 2026-07-30): builder-aware for Jinja2.** FR-016's uniform "watch absent -> fold commit" rule is superseded for Jinja2 transforms. The commit-id gate is now builder-aware: + > + > - **Jinja2**: stability is gated on `dependencies_complete` alone. Its completeness flag is *sound* - it comes from a real transitive parse of the template `include`/`import`/`extends` graph and is only True when every reference resolved to a tracked template. A complete closure yields a stable, precise fingerprint with **no `watch` required**; a dynamic/unresolvable reference drops `dependencies_complete` to False, which keeps the commit id folded. `watch.files` remains the escape hatch to declare targets auto-detection cannot see. + > - **Python transforms / generators**: FR-016 stands unchanged. Their `dependencies_complete = True` is only the *package-directory floor* (an out-of-directory import is silently missed), so it is *unsound* as a completeness guarantee. A present `watch` remains the only signal trusted to omit the commit id. + > + > Rationale: requiring a no-op `watch: {}` on a Jinja2 transform to unlock a stability the system could already justify was pure ceremony. No under-regeneration is introduced - Python is untouched, and incomplete Jinja2 closures still fold the commit id. Implemented via the `watch_required` branch in `fold_commit_id` (`backend/infrahub/git/fingerprint/composer.py`). See Jira IFC-2952 for the full analysis. - **FR-017**: When `watch` is an explicit empty list (`[]`), the system MUST omit the commit-id placeholder, producing a stable, precise fingerprint (user opt-in asserting no dependencies beyond the auto-detected closure). - **FR-018**: When `watch` lists specific files, the system MUST extend the closure with those files, treat the closure as complete, and produce a stable, precise fingerprint. -- **FR-019**: The watch configuration MUST be able to represent absent (`None`) and explicitly-empty (`[]`) as distinct states, and the fingerprint computation MUST branch on that distinction (absent -> fold commit id; explicitly-empty -> do not). NOTE: today the closure builder treats "watch absent" and "watch present but with no files" identically (both skip closure expansion), so no consumer currently distinguishes them. This feature must introduce that distinction for fingerprint purposes. The current config shape is an object (`watch:` with a `files` list), not a bare list; the epic's `watch: []` / `watch: [files]` shorthand maps onto "the `watch:` key is present" (explicit, so stable) versus absent (so unstable). Any change to the config model or its parsing to make the two states distinguishable is in scope. +- **FR-019**: The watch configuration MUST be able to represent absent (`None`) and explicitly-empty (`[]`) as distinct states, and the fingerprint computation MUST branch on that distinction (absent -> fold commit id; explicitly-empty -> do not). NOTE: today the closure builder treats "watch absent" and "watch present but with no files" identically (both skip closure expansion), so no consumer currently distinguishes them. This feature must introduce that distinction for fingerprint purposes. The current config shape is an object (`watch:` with a `files` list), not a bare list; the epic's `watch: []` / `watch: [files]` shorthand maps onto "the `watch:` key is present" (explicit, so stable) versus absent (so unstable). Any change to the config model or its parsing to make the two states distinguishable is in scope. **Amended by IFC-2952 (see FR-016):** the absent-vs-empty distinction only governs Python transforms and generators; for Jinja2 both states produce a stable fingerprint when the closure is complete, because Jinja2 completeness is sound on its own. #### Scope guards and completeness diff --git a/dev/specs/telemetry-collection-infp-589/alignment-check.md b/dev/specs/telemetry-collection-infp-589/alignment-check.md new file mode 100644 index 00000000000..047c29bf5c8 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/alignment-check.md @@ -0,0 +1,82 @@ +# Spec/Ask Alignment Check: Phase 1 Telemetry Collection + +**Date**: 2026-06-28 +**Remediation passes used**: 0 + +## 1. Source + +Inline PRD: `INFP-589-phase1-handoff.md` (the feature-description block + Functional +Requirements + Success Criteria + Code pointers + Governance gate + Parked decision). No URLs +present in the ask — Jira/JPD items are referenced by ID only, so no web fetch was needed. + +## 2. Verdict + +✅ **ALIGNED** — no *unintended* drift. The spec is a faithful, expanded restatement of the +PRD. Additions are either necessary clarifications or one **sanctioned, user-directed scope +expansion** (checks/artifacts metrics — see §6), explicitly recorded rather than silently +folded in. + +## 3. Findings + +| Severity | Category | PRD reference | Spec reference | Description | +|----------|----------|---------------|----------------|-------------| +| ✅ none | — | FR-001..003, 005..011 | FR-001..003, 005..011 | All in-scope FRs present verbatim in intent; FR-004 correctly omitted (blocked). Numbering matches the PRD exactly. | +| ✅ none | — | "Success" SC-001/002/003 | SC-001/002/003 | All three success criteria carried over with the same meaning (presence+null-on-failure, exact 24h window, corenode ±0). | +| ✅ none | — | "Governance gate" | GR-001 | Receiving-end confirmation captured as a release gate. | +| ✅ none | — | "Out of scope" | Out of Scope section | user_node_count, Phase 2 items, dashboards, redefining node_count.total, persisting logins — all preserved. | +| ℹ️ info (added, justified) | added | FR-011 + Governance gate | SC-004 | Spec adds SC-004 (additive-only + format-bump consumer compatibility). This is a measurable restatement of FR-011 + the governance gate, not new scope. | +| ℹ️ info (added, justified) | added | — (engineering clarification) | GR-001(c), SC-004, contract | Critique added explicit "consumer tolerates `null` values (incl. corenode in node_count)" tolerance. This clarifies the additive contract implied by FR-010/FR-011 + the `node_count.corenode` requirement; it does not change scope. | +| ℹ️ info (expansion) | — | "Facts" / best-effort note | Edge Cases + Assumptions | PRD's best-effort/retention facts expanded into edge cases and assumptions. Expansion of detail, allowed. | + +No `missing`, `changed`, `dropped`, or `contradicted` findings. + +## 4. Action + +Proceed. `tasks.md` is generated and aligned with the source PRD. No phases re-run. + +## 5. Post-review refinements (2026-06-28, reviewer feedback) + +Two design refinements applied after the user reviewed the prep output. Both keep the spec +aligned with the PRD — they make existing requirements precise, they do not add/drop scope: + +1. **24h window anchoring (sharpens SC-002).** The daily flow runs at a per-deployment-random + minute (`cron=f"{random.randint(0, 59)} 2 * * *"`, `workflows/catalogue.py`). Anchoring the + window to gather-time `now` would make consecutive daily windows overlap or gap under + execution drift — violating SC-002's "no overlap/leakage". The window is now anchored to a + deterministic boundary: the **previous full UTC calendar day** `[midnight-24h, midnight)`. + Updated in spec (SC-002, edge cases, assumptions, `activity_24h` entity), plan (constraints), + research (Decision 3 + 4), data-model, contract, and tasks (new T009b helper + T007/T010/T012). +2. **Node-metric definitions pinned at the namespace level (sharpens FR-009).** Verified via + `get_labels()` that `corenode` = all `CoreNode`-labelled nodes (`Core` + `Builtin` + + user-defined namespaces), which always includes the non-empty `Core` management namespace; + the future `user` metric excludes `Core`, so `user ⊆ corenode ⊆ total` strictly and they can + never become synonyms (relevant because FR-011 forbids removing a shipped field). Updated in + spec (FR-009), research (Decision 1), data-model, contract, and tasks (T021). + +## 6. Sanctioned scope expansion — checks, artifacts & branch lifecycle (2026-06-28, user-directed) + +| Category | PRD reference | Spec reference | Description | +|----------|---------------|----------------|-------------| +| added (approved) | **not in PRD** (came from the JPD card's Phase 2 list, not the handoff) | FR-012, FR-013, FR-014, US5, `activity_24h.checks_*` / `artifacts_*` / `branches_*` | Pulled `validator.started/passed/failed` → `checks_*`, `artifact.created/updated` → `artifacts_*`, and `branch.created/merged/deleted` → `branches_*` into Phase 1. | + +**Why this is NOT unresolved drift**: The user explicitly directed this across two review +rounds (checks/artifacts, then branch-lifecycle counts after challenging the cost). It is a +deliberate, recorded expansion — the alignment phase exists to surface exactly this kind of +divergence rather than let it pass unnoticed, and here it is surfaced and approved. + +**Why it is safe / cheap**: All three event families are **already emitted and counted today** +(verified via `get_all_events()`), so they reuse the US1 windowed event path unchanged (one more +event name per metric + a parametrized test). They serve the already-stated Phase 1 +"depth-of-adoption" goal; branch create/merge/delete activity is an especially direct adoption +signal for the branch-based workflow. + +**Boundary discipline applied** — events that exist but were deliberately **held in Phase 2**, +to keep permanent (FR-011) contract surface to clean standalone signals: +- **PR "merged-without-review"** (`proposed_change.*`) — needs per-PR review↔merge correlation. +- **Branch *lifetime*** (create→merge duration) — needs durable per-branch correlation. (The + lifecycle *counts* are in scope; only the duration is deferred.) +- **Node churn** (`node.*`) — `node.updated` fires on every attribute mutation incl. automated + writes, so the count is machine-dominated (held on signal quality, not cost). +- **Branch `rebased`/`migrated` counts** — maintenance/automation-driven, lower-signal. + +Recorded in spec "Out of Scope", research Decision 9, and tasks Notes. diff --git a/dev/specs/telemetry-collection-infp-589/checklists/requirements.md b/dev/specs/telemetry-collection-infp-589/checklists/requirements.md new file mode 100644 index 00000000000..c8895f202ed --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Phase 1 Telemetry Collection + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Field names (`accounts.active`, `activity_24h.*`, `database.node_count.corenode`, + etc.) are retained in the spec because they constitute the externally-observable + payload **contract** — the deliverable itself — not implementation detail. The + spec deliberately avoids prescribing how each value is computed (no class names, + query mechanics, or module paths); those live in plan.md. +- The handoff supplied a detailed PRD with grounded code pointers. Those pointers + are intentionally deferred to the planning phase rather than embedded here. +- FR-004 is intentionally absent (blocked, out of scope) — numbering matches the + source PRD's FR list, which skips FR-004 in this feature. diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md new file mode 100644 index 00000000000..f608fe6d0b9 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -0,0 +1,152 @@ +# Contract: Telemetry Payload (Phase 1 additions) + +The contract is the **daily telemetry payload** Infrahub emits to the remote endpoint and +stores locally. This document defines the additive changes. The consumer (cloud processor + +data mart) is forward-compatible: it ignores unknown fields, so additive changes are safe. + +## Envelope (unchanged shape, bumped version) + +```jsonc +{ + "kind": "community", + "payload_format": "20260628", // BUMPED from "20250318" + "data": { /* TelemetryData — see below */ }, + "checksum": "" +} +``` + +- `payload_format` advances to `"20260628"` (FR-007). Convention: `YYYYMMDD`. +- A consumer keying on `payload_format` must tolerate the new value (GR-001 confirmation gate). + +## `data` additions + +```jsonc +{ + // ... all existing fields unchanged ... + + "branches": { + "total": 12, // unchanged + "active": 4 // NEW: open non-system branches; int | null + }, + + "accounts": { // NEW object + "active": 7, // int | null + "groups": 3 // int | null + }, + + "database": { + // ... + "node_count": { + "total": 154233, // unchanged: raw vertex total + "corenode": 4821, // NEW key: all managed nodes; int | null + "user": 3902, // NEW key: user/business nodes (user-defined namespaces); int | null + // ... existing graph-label keys unchanged ... + } + }, + + "activity_24h": { // NEW object — previous full UTC calendar day [00:00, 00:00) + "logins": 19, // int | null + "unique_logins": 6, // int | null + "checks_started": 88, // int | null + "checks_passed": 80, // int | null + "checks_failed": 8, // int | null + "artifacts_created": 14, // int | null + "artifacts_updated": 31, // int | null + "branches_created": 9, // int | null + "branches_merged": 5, // int | null + "branches_deleted": 4, // int | null + "webhooks_fired_success": 41, // int | null + "webhooks_fired_failure": 2 // int | null + } +} +``` + +## Field semantics + +The 24h window is the **previous full UTC calendar day** `[window_start, window_end)` with +`window_end = floor_to_midnight_utc(now)`, `window_start = window_end - 24h` — anchored to a +deterministic boundary (not gather-time `now`) so daily snapshots tile exactly. + +| Path | Source | Window | Empty | Failure | +|------|--------|--------|-------|---------| +| `branches.active` | registry: `branch.values()` minus `is_default`/`is_global` | current | `0` | `null` | +| `accounts.active` | `NodeManager.count(CoreAccount, status=ACTIVE)` | current | `0` | `null` | +| `accounts.groups` | `NodeManager.count(CoreAccountGroup)` | current | `0` | `null` | +| `database.node_count.corenode` | `NodeManager.count(CoreNode)` | current | `0` | `null` | +| `database.node_count.user` | sum of `NodeManager.count` over user-defined-namespace kinds | current | `0` | `null` | +| `activity_24h.logins` | Prefect `account.logged_in` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.unique_logins` | Prefect count-by-resource on login events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.checks_started` | Prefect `validator.started` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.checks_passed` | Prefect `validator.passed` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.checks_failed` | Prefect `validator.failed` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.artifacts_created` | Prefect `artifact.created` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.artifacts_updated` | Prefect `artifact.updated` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.branches_created` | Prefect `branch.created` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.branches_merged` | Prefect `branch.merged` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.branches_deleted` | Prefect `branch.deleted` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.webhooks_fired_success` | Prefect `webhook-process` flow runs, `COMPLETED` | prev. UTC day | `0` | `null` | +| `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED` | prev. UTC day | `0` | `null` | + +**Interpretation notes (checks vs webhooks).** + +- The three `checks_*` fields are **not additive**: `checks_started` is the *denominator* + (validation runs initiated in-window), while `checks_passed`/`checks_failed` are terminal + outcomes. Consumers derive pass rate (`passed/started`), failure rate (`failed/started`), and + incomplete/crash rate (`1 − (passed+failed)/started`). Do **not** sum all three. +- `webhooks_*` is intentionally **outcomes-only** this phase (no `webhooks_attempted` + denominator), so only absolute success/failure counts are available — not a webhook failure + *rate*. This asymmetry with `checks_*` is deliberate; an attempted/started count can be added + later additively if rate analysis is needed, without breaking the contract. + +**Node-count metrics (FR-009).** `node_count` carries three semantically distinct, strictly +nesting keys — `user ⊆ corenode ⊆ total`: + +| Key | Counts | Namespace scope | +|-----|--------|-----------------| +| `total` | raw vertices | n/a (raw graph) | +| `corenode` | all managed nodes; **incl. `Core`-namespace pipeline validators/checks** (so inflatable by proposed-change activity) | `Core` + `Builtin` + user-defined | +| `user` | customer-facing subset | user-defined only (namespace ∉ `RESTRICTED_NAMESPACES`) — excludes `Core` (incl. pipeline validators/checks) and `Builtin` (so `BuiltinTag` uncounted) | + +`corenode` always includes the `Core` management namespace (always non-empty); `user` never +does — so the two can never coincide. **Consumer caveat:** read `corenode` as a +total-managed-footprint number (it rises and falls with proposed-change pipeline volume), and +`user` as the clean customer-data-scale number. + +## Invariants the consumer can rely on + +1. **Additive only.** No existing field changes name, type, or meaning. (`node_count` value + type widens to allow `null` only on the new `corenode`/`user` keys; existing keys stay `int`.) +2. **`null` means failure, `0` means empty.** A field is `null` iff its source raised during + gathering; `0` iff the source succeeded with nothing to count. (FR-010, SC-001) +3. **Payload always ships.** One failing metric never drops the payload; the rest is gathered, + stored, and sent. (SC-001) +4. **24h fields are exact to the window.** No leakage from retained-but-out-of-window records. + (SC-002) +5. **`corenode` and `user` are branch/temporal-correct.** `corenode` matches an independent + fixture count exactly; `user` excludes `Core`/`Builtin` nodes, with `user ⊆ corenode ⊆ total`. + (SC-003) + +## Internal interface contracts (producer side) + +These are the new/changed producer-side function contracts (full signatures land in `tasks.md`): + +- `gather_account_information(db) -> TelemetryAccountData` — both fields via `NodeManager.count`, + each degradable to `null`. +- `gather_database_information(db) -> TelemetryDatabaseData` — extended to set + `node_count["corenode"]` via `NodeManager.count(CoreNode)` and `node_count["user"]` via the + sum of `NodeManager.count` over user-defined-namespace kinds; each independently degradable to + `null` without touching `node_count["total"]` or the graph-label keys. +- `gather_activity_24h(client) -> TelemetryActivity24hData` — windowed login count, windowed + unique-login (count-by-resource) count, and `webhook-process` flow-run success/failure split, + each field degradable to `null`. +- `gather_prefect_events(client)` — **UNCHANGED** (existing unwindowed tally; FR-007). +- A degradation helper in `tasks.py`: runs a metric coroutine, returns its value or `null` on + exception (logged). Serves all new metrics. + +## Governance (GR-001) + +Before shipping: confirm the cloud processor and BigQuery/Metabase data mart (a) tolerate the +`payload_format` bump, (b) ignore unknown fields, and (c) tolerate `null` values on the new +fields — including a `null` on the new `corenode`/`user` keys inside `node_count`, the only +place a previously all-integer map can now carry a `null`. Additive design means a +forward-compatible consumer keeps working; this is a release gate, not a code dependency. diff --git a/dev/specs/telemetry-collection-infp-589/critique.md b/dev/specs/telemetry-collection-infp-589/critique.md new file mode 100644 index 00000000000..84c27c5b21a --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/critique.md @@ -0,0 +1,37 @@ +# Critique Report: Phase 1 Telemetry Collection + +**Date**: 2026-06-28 +**Lenses**: Product + Engineering +**Inputs**: `spec.md`, `plan.md` (+ research/data-model/contracts) +**Verdict**: ✅ PROCEED (Must-Address findings applied inline) + +## Product lens + +The spec is well-scoped and producer-only with crisp in/out-of-scope boundaries, prioritized +independently-testable user stories, measurable success criteria, and a governance gate +(GR-001). The blocked `user_node_count` (IFC-2825) and Phase 2 items are explicitly excluded. +No scope creep observed relative to the PRD. No product-level Must-Address items. + +## Engineering lens + +The plan is grounded in the actual telemetry module. Branch-safety (Constitution II) is honored +by routing node/account counts through `NodeManager.count` and explicitly avoiding raw label +counts for `corenode`. The existing unwindowed event tally is preserved (FR-007). Per-metric +graceful degradation is the right shape for FR-010/SC-001. Two Must-Address gaps were found and +fixed; two recommendations were applied. + +## Findings + +| # | Severity | Lens | Finding | Resolution | +|---|----------|------|---------|------------| +| 1 | 🎯 Must-Address | Eng | Test strategy for SC-001/SC-002 was an "open consideration" and didn't name a deterministic-time approach — risk of slipping into `unittest.mock`, which `testing-python.md` forbids. | Firmed up in `research.md` Decision 7: degradation helper unit-tested directly with raising/returning coroutines (no mock); `freezegun` pins time for windowing; Prefect `.fn`/logger handled via the allowed pattern. | +| 2 | 🎯 Must-Address | Eng/Prod | Widening `node_count` to `dict[str, int \| None]` means a previously all-integer map can carry a `null` on `corenode`. GR-001/SC-004 only mentioned "ignores unknown fields", not "tolerates null values". A strict consumer could choke. | GR-001 + SC-004 (spec) and the contract now explicitly require the consumer to tolerate `null` values, calling out the `corenode`-in-`node_count` case. | +| 3 | 💡 Recommendation | Eng | Behavior of in-window-but-non-terminal `webhook-process` runs was unspecified. | Documented in `data-model.md` + `research.md` Decision 8: non-terminal runs counted as neither success nor failure (trend-signal semantics). | +| 4 | 💡 Recommendation | Eng | `NodeManager.count(CoreNode)` cost on large deployments (Constitution V) unaddressed. | `research.md` Decision 8: single aggregate query, daily batch — acceptable; no benchmark required this phase, deliberate over per-label summation. | +| 5 | 🤔 Question | Prod | Does `branches.active` include open-but-merged branches lingering in the registry? | Resolved: registry membership already means "open" (closed/deleted branches are evicted), consistent with the existing `branches.total`. No change. | + +## Constitution re-check (post-critique) + +All seven principles still pass. The `node_count` type-widening is the only contract subtlety +and is now explicitly gated (GR-001) and documented as additive-in-practice (no existing key +ever `null`). No new entities, no new dependencies, no schema changes. diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md new file mode 100644 index 00000000000..cf3718841ae --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -0,0 +1,124 @@ +# Phase 1 Data Model: Phase 1 Telemetry Collection + +This feature adds no database schema entities. The "data model" here is the **telemetry +payload model** (Pydantic models in `backend/infrahub/telemetry/models.py`). All changes are +additive. + +## New models + +### `TelemetryAccountData` + +| Field | Type | Meaning | Empty | Failure | +|----------|--------------|----------------------------------------------------|-------|---------| +| `active` | `int \| None`| Count of `CoreAccount` with `status == ACTIVE` | `0` | `null` | +| `groups` | `int \| None`| Count of `CoreAccountGroup` | `0` | `null` | + +Counts via `NodeManager.count` on the default branch (branch/temporal-correct). + +### `TelemetryActivity24hData` + +The window is the **previous full UTC calendar day** `[window_start, window_end)` where +`window_end = floor_to_midnight_utc(now)` and `window_start = window_end - 24h` — anchored to a +deterministic calendar boundary, NOT to gather-time `now`, so consecutive daily runs tile +exactly (no overlap, no gap) despite the jittered cron minute and execution drift. + +| Field | Type | Meaning | Empty | Failure | +|---------------------------|---------------|---------------------------------------------------------|-------|---------| +| `logins` | `int \| None` | `account.logged_in` events in the windowed day | `0` | `null` | +| `unique_logins` | `int \| None` | Distinct `account_id` among those logins (same window) | `0` | `null` | +| `checks_started` | `int \| None` | `validator.started` events in-window | `0` | `null` | +| `checks_passed` | `int \| None` | `validator.passed` events in-window | `0` | `null` | +| `checks_failed` | `int \| None` | `validator.failed` events in-window | `0` | `null` | +| `artifacts_created` | `int \| None` | `artifact.created` events in-window | `0` | `null` | +| `artifacts_updated` | `int \| None` | `artifact.updated` events in-window | `0` | `null` | +| `branches_created` | `int \| None` | `branch.created` events in-window | `0` | `null` | +| `branches_merged` | `int \| None` | `branch.merged` events in-window | `0` | `null` | +| `branches_deleted` | `int \| None` | `branch.deleted` events in-window | `0` | `null` | +| `webhooks_fired_success` | `int \| None` | `webhook-process` flow runs in-window ending `COMPLETED`| `0` | `null` | +| `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in-window ending `FAILED`/`CRASHED` | `0` | `null` | + +The eight check/artifact/branch fields are derived from events that are **already emitted and +counted today** (windowless) via `get_all_events()`; they reuse the windowed event-count path +unchanged — each is one more event name in the same query. They serve "depth of adoption". +Branch lifecycle *counts* are in scope; branch *lifetime* (create→merge duration) is not — it +needs per-branch correlation. + +Each field is isolated: one failing source nulls only its own field. A `webhook-process` run +that started in-window but is still non-terminal (`PENDING`/`RUNNING`/`SCHEDULED`) at gather +time is counted in neither success nor failure — only terminal outcomes are tallied (a +best-effort daily trend signal). + +## Extended models + +### `TelemetryBranchData` (extended) + +| Field | Type | Status | Meaning | +|----------|---------------|------------|--------------------------------------------------------------| +| `total` | `int` | unchanged | Existing total branch count (`len(registry.branch)`). | +| `active` | `int \| None` | **new** | Open non-system branches (exclude `main` / `-global-`). `0` empty, `null` failure. | + +### `TelemetryDatabaseData.node_count` (value type widened) + +| Key | Type widening | Status | Meaning | +|----------------|------------------------------|------------|-------------------------------------------------------------| +| `node_count` | `dict[str, int]` → `dict[str, int \| None]` | **widened** | Holds existing keys (`total`, graph labels) + new `corenode`, `user`. | +| `…["total"]` | `int` | unchanged | Raw vertex total (`count_nodes(db)`). | +| `…["corenode"]`| `int \| None` | **new key**| Managed-node count via `NodeManager.count(CoreNode)`. `0` empty, `null` failure. | +| `…["user"]` | `int \| None` | **new key**| User/business-node count: sum of `NodeManager.count` over node kinds in user-defined (non-restricted) namespaces. `0` empty, `null` failure. | + +Widening is additive in practice: existing keys are always populated `int`; only `corenode` and +`user` may be `null`. No existing key changes meaning or name (FR-011). + +**Three node metrics, defined at the namespace level (FR-009).** `CoreNode` is applied to every +node outside the `Schema`/`Internal` namespaces (and non-groups), so the three nest strictly — +`user ⊆ corenode ⊆ total`: + +| Key | Counts | Namespace scope | +|-----|--------|-----------------| +| `total` | raw vertices (incl. attributes/values/internal bookkeeping) | n/a (raw graph) | +| `corenode` | all managed nodes; **incl. `Core`-namespace pipeline validators/checks**, so it can be inflated by proposed-change activity | `Core` + `Builtin` + user-defined | +| `user` | customer-facing subset | user-defined namespaces only (namespace ∉ `RESTRICTED_NAMESPACES`) — excludes `Core` (incl. pipeline validators/checks) and `Builtin` (so `BuiltinTag` is not counted) | + +`user` is computed as the sum of `NodeManager.count` over concrete node kinds whose namespace is +user-editable (`namespace not in RESTRICTED_NAMESPACES`), on the default branch — the negative +filter Patrick specified. Group-generic kinds are excluded (they don't carry the `CoreNode` +label), preserving `user ⊆ corenode`. Because the `Core` management namespace is always +non-empty and always in `corenode` but never in `user`, the two can never collapse into the same +value. + +### `TelemetryData` (root, extended) + +| Field | Type | Status | +|----------------|----------------------------|---------| +| `accounts` | `TelemetryAccountData` | **new** | +| `activity_24h` | `TelemetryActivity24hData` | **new** | +| `branches` | `TelemetryBranchData` | extended (see above) | +| `database` | `TelemetryDatabaseData` | extended (node_count) | +| *(all other existing fields)* | unchanged | unchanged | + +The two new root objects are always present; per-metric nullability lives on their fields, so +a whole-source failure surfaces as nulled fields, never a missing object (SC-001). + +## Validation / invariants + +- **Additive**: no existing field renamed, retyped (except the documented `node_count` value + widening), or removed (FR-011). +- **null vs 0**: `null` ⇔ source raised; `0` ⇔ source succeeded with nothing to count + (FR-010, SC-001). +- **Windowing**: event/flow-run metrics count only records whose occurrence/start is within + the trailing 24h (SC-002). +- **Branch-correct**: `corenode`, `user`, `accounts.*` computed on the default branch via + `NodeManager.count` (Constitution II); `corenode` must equal an independently-computed + fixture exactly, and `user` must exclude seeded `Core` nodes with `user ⊆ corenode ⊆ total` + (SC-003). + +## Out of model (this phase) + +- No changes to `TelemetryPrefectData.events` (the existing unwindowed tally). +- `database.system_info.processor_configured` (configured DB core count, intended for future + license reporting) is **deferred**. It was prototyped reading Neo4j `SHOW SETTINGS` for + `server.threads.worker_count`, but that setting is REST-only — it does not govern the Bolt + path Infrahub uses — and defaults to the host core count, so today it would only duplicate + `processor_available`. Revisit once the correct "licensed cores" setting is confirmed: Neo4j + exposes no single canonical one (`server.cypher.parallel.worker_limit` is the other + candidate, or the true signal may be the JVM/container CPU allocation). diff --git a/dev/specs/telemetry-collection-infp-589/manual-test-runbook.md b/dev/specs/telemetry-collection-infp-589/manual-test-runbook.md new file mode 100644 index 00000000000..1a32a594a6e --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/manual-test-runbook.md @@ -0,0 +1,125 @@ +# Telemetry Phase 1 — Manual Test Plan + +Validates the daily telemetry payload end to end on a running Infrahub instance. It assumes you +can already spin up Infrahub (the standard dev stack via `uv run invoke dev.start`) and have an +admin API token. For the concepts behind these metrics — categories, windowing, retention, +degradation — see `dev/knowledge/backend/telemetry.md`. + +## What you are validating + +The Phase 1 payload additions: + +- **Point-in-time**: `accounts.active`, `accounts.groups`, `branches.active`, + `database.node_count.corenode`, `database.node_count.user` — checked against an independent + source (GraphQL) or an invariant. +- **Windowed**: the `activity_24h.*` block (logins, checks, artifacts, branch actions, webhook + deliveries) — checked with the bundled `window_probe.py`. + +## Prerequisites + +Container names below assume the default `infrahub` compose project; adjust if yours differ. + +```fish +set -x INFRAHUB_ADDRESS http://localhost:8000 +set -x INFRAHUB_API_TOKEN (docker exec infrahub-server-1 printenv INFRAHUB_INITIAL_ADMIN_TOKEN) +set P /source/dev/specs/telemetry-collection-infp-589/window_probe.py # probe path inside the worker +``` + +The repo is bind-mounted into the worker at `/source`, so the probe runs there without copying. + +## 1. Trigger a collection on demand + +The flow runs daily at ~02:00 UTC; trigger it now instead of waiting: + +```bash +docker exec infrahub-task-worker-1 prefect deployment run 'anonymous_telemetry_send/anonymous_telemetry_send' +``` + +Wait ~20s for a worker to pick it up. + +> With `telemetry_optout=false` (default) this also POSTs one anonymous payload to the real +> endpoint — the same thing the daily cron does. The snapshot is stored locally *before* the +> send regardless, so inspection never depends on it. To avoid the send, set +> `INFRAHUB_TELEMETRY_OPTOUT=true` on the worker and recreate it first. + +## 2. See the payload + +```fish +uv run infrahubctl telemetry list # newest row = the run you just triggered +uv run infrahubctl telemetry export --output /tmp/t.json +python3 -c " +import json +d = json.load(open('/tmp/t.json'))[0]['data'] +print('accounts :', d['accounts']) +print('branches :', d['branches']) +print('node_count :', {k: d['database']['node_count'][k] for k in ('total', 'corenode', 'user')}) +print('activity_24h:', json.dumps(d['activity_24h'], indent=2)) +" +``` + +## 3. Validate the point-in-time metrics + +These reflect current state, so trigger a fresh snapshot (step 1) before comparing. + +`accounts` — GraphQL counts must equal `accounts.active` / `accounts.groups`: + +```bash +curl -s -H "X-INFRAHUB-KEY: $INFRAHUB_API_TOKEN" -H "Content-Type: application/json" \ + -d '{"query":"query { active: CoreAccount(status__value:\"active\"){count} groups: CoreAccountGroup{count} }"}' \ + http://localhost:8000/graphql/main +``` + +`branches.active` — GraphQL branch list minus `main` must equal it (`branches.total` is 2 higher: +it also counts `main` and the internal `-global-` branch): + +```bash +curl -s -H "X-INFRAHUB-KEY: $INFRAHUB_API_TOKEN" -H "Content-Type: application/json" \ + -d '{"query":"query { Branch { name is_default } }"}' http://localhost:8000/graphql/main \ + | python3 -c "import sys,json;b=json.load(sys.stdin)['data']['Branch'];print('active =', len([x for x in b if not x['is_default']]))" +``` + +`node_count` — verify the invariant from step 2's output: `user ≤ corenode ≤ total`, and +`user < corenode` (the always-present `Core` management namespace lifts `corenode` above `user`). + +## 4. Validate the windowed metrics (`activity_24h`) + +`window_probe.py` counts one metric in three windows: **YESTERDAY** (what the snapshot reports), +**TODAY** (what tomorrow's snapshot will report), **LAST 3H** (fresh activity). + +```bash +docker exec infrahub-task-worker-1 python $P logins +``` + +Two checks: + +1. **Consistency** — the snapshot's `activity_24h.` equals the probe's **YESTERDAY** count. +2. **Windowing (live)** — do the action now (e.g. `uv run infrahubctl branch create test-$(date +%s)`, + or log in at the UI), re-run the probe: **TODAY** and **LAST 3H** rise while **YESTERDAY** stays + frozen. That is the guarantee — today's events never leak into yesterday's closed window. + +Metrics accepted: `logins`, `branches_created` / `_merged` / `_deleted`, `checks_started` / +`_passed` / `_failed`, `artifacts_created` / `_updated`, and `webhooks` (success/failure). + +## 5. Graceful degradation + +`null` means a source failed (and was logged); `0` means it was measured with nothing to count. A +healthy run has no `null`s. To see a source degrade in isolation without breaking the rest, point +one metric's source at a failing dependency — only that field goes `null`. + +## Caveats worth knowing + +- **`checks_passed` / `checks_failed`** are only emitted for validators that run the checks + runner. A trivial proposed change concludes its integrity validators without executing checks, + so it produces `checks_started` only — `started` can exceed `passed + failed` with nothing + actually incomplete. To move `passed`/`failed`, use a proposed change with real conflicts or a + connected repository with checks. +- **`webhooks_fired_*`** counts `webhook-process` flow-runs. In a bare dev stack the + event → automation delivery may not fire on its own; generate real webhook traffic (a webhook + subscribed to `all` events plus a triggering mutation) to exercise it. + +## The ad-hoc probe (`window_probe.py`) + +The script lives beside this file. It evaluates the production windowed counters against a chosen +reference time, so you can confirm just-now activity is captured without waiting for the calendar +day to roll. Run it inside a worker container (which has the Prefect client and `PREFECT_API_URL` +configured); pass an `activity_24h` field name as the argument (defaults to `logins`). diff --git a/dev/specs/telemetry-collection-infp-589/opsmill-implement-report.md b/dev/specs/telemetry-collection-infp-589/opsmill-implement-report.md new file mode 100644 index 00000000000..9a6f0923aa2 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/opsmill-implement-report.md @@ -0,0 +1,71 @@ +# Implementation Report: Phase 1 Telemetry Collection + +## 1. Header + +- **Feature**: Phase 1 Telemetry Collection +- **Spec dir**: `specs/telemetry-collection-infp-589` (real path `dev/specs/telemetry-collection-infp-589`) +- **Base commit**: `802234224` +- **Head commit**: `829197d1a` +- **Branch**: `telemetry-collection-infp-589` +- **Run status**: ✅ COMPLETE (all code tasks done; local-pass evidence has no MISSING rows). One **process** task (T030 governance gate) remains open as an external pre-merge dependency. +- **Mode**: interactive, clean-context subagent per chunk; path-scoped commits (the working tree had unrelated pre-existing dirty files, which were kept out of every commit). + +## 2. Chunk-by-chunk ledger + +| # | Chunk (phase) | Tasks | Outcome | Commit | Notes flagged upward | +|---|---------------|-------|---------|--------|----------------------| +| 1 | Setup | T001–T002 | 2 ✅ | `5b9622599` | Changelog `added` category; test skeletons collect cleanly. | +| 2 | Foundational | T003–T006 | 4 ✅ | `0805c3658` | Helper named `safe_metric` (PEP 695 generic); `node_count` widened to `dict[str,int\|None]`; placeholder `accounts`/`activity_24h` left at call site for later wiring. | +| 3 | US1 activity_24h enabler | T007–T014 (+T009b) | 9 ✅ | `2656de3c8` | **Prefect is 3.7.5** (not 2026.05 as AGENTS.md claims). **`TIMEDOUT` is not a real StateType** → failure = `FAILED`+`CRASHED`. Flow-run `start_time` not client-settable. `freezegun` not installed → window helper takes explicit `now`; `safe_metric` moved to `utils.py` (import-cycle) and re-exported. | +| 4 | US2 accounts/branches | T015–T018 | 4 ✅ | `5931d35a0` | Status filter `status__value="active"`. Subagent omitted its evidence block; orchestrator **independently re-ran** the tests to capture it (below). | +| 5 | US3 corenode | T019–T021 | 3 ✅ | `6dde986c2` | Independent oracle = raw label count (distinct path from `NodeManager.count`); TDD red `KeyError: 'corenode'` → green. | +| 6 | US5 checks/artifacts/branches | T022–T023 | 2 ✅ | `f5f839f38` | Real event-name constants; removed 8 now-stale `None` assertions from the US1 test. | +| 7 | US4 resilience | T024–T026 | 3 ✅ | `b1da82dbf` | No-mock seam = 3 optional injected async callables with prod defaults; T026 audit wrapped orchestrator-level assembler calls in `safe_metric`; caught a real cross-test flake + a `registry.id` teardown leak via self-review. | +| 8 | Polish + review fix | T027–T030 | 3 ✅, 1 open | `829197d1a` | Suite green, lint clean, local simulation done. **Review fix applied** (webhook count — see §5). T030 governance gate open. | + +## 3. Tasks not completed + +- **T030 — Governance gate (GR-001)**: confirm the cloud-processor + data-mart owners tolerate the `payload_format` bump, ignore unknown fields, and tolerate `null` values (incl. `corenode` in `node_count`). This is an explicit **process/external** task, not code; it must be done before merge/release. No code or test depends on it. + +All implementation tasks T001–T029 are `[X]`. + +## 4. Local-pass evidence + +All tests run with `DOCKER_HOST=unix:///Users/Dimitris/.docker/run/docker.sock` (component tests use testcontainers Neo4j + ephemeral Prefect; unit tests need neither). Authoritative post-everything run: **`62 passed`** at `2026-06-29T08:25:08Z` (`uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q`). + +| Test id | Type | Run command | Passed at (ISO 8601) | Env | Verbatim pass line | +|---------|------|-------------|----------------------|-----|--------------------| +| `test_degradation.py::test_raising_coroutine_degrades_to_none`, `::test_zero_result_is_preserved`, `::test_non_zero_result_is_preserved` | unit | `uv run pytest backend/tests/unit/telemetry/test_degradation.py -v` | 2026-06-28T21:36:07Z | n/a (no DB/mock; plain coroutines) | `3 passed, 16 warnings in 0.11s` | +| `test_task_manager.py::test_window_is_previous_full_utc_day`, `::test_floor_to_midnight_utc`, `::test_windowed_logins_count`, `::test_windowed_unique_logins_count`, `::test_windowed_logins_exclude_out_of_window`, `::test_webhook_success_failure_split`, `::test_webhook_split_excludes_out_of_window`, `::test_gather_activity_24h_logins`, `::test_gather_prefect_events_unchanged` | component | `uv run pytest backend/tests/component/telemetry/test_task_manager.py -v -p no:randomly` | 2026-06-28T21:53:06Z | testcontainers Neo4j + prefect_test_fixture | `10 passed, 16 warnings in 10.95s` (incl. `test_gather_prefect_information`) | +| `test_task_manager.py::test_gather_activity_24h_checks_artifacts_branches[checks_started\|checks_passed\|checks_failed\|artifacts_created\|artifacts_updated\|branches_created\|branches_merged\|branches_deleted]` | component | `uv run pytest backend/tests/component/telemetry/test_task_manager.py -v -p no:randomly` | 2026-06-29T00:00:00Z | testcontainers Neo4j + prefect_test_fixture | `18 passed, 16 warnings in 12.01s` | +| `test_tasks.py::test_gather_account_information_counts`, `::test_active_branches_excludes_default_and_global` | component | `uv run pytest backend/tests/component/telemetry/test_tasks.py -v -p no:randomly` | 2026-06-28T22:05:50Z (orchestrator-captured) | testcontainers Neo4j + registry | `2 passed, 16 warnings in 21.04s` | +| `test_tasks.py::test_gather_full_payload_fields_present`, `::test_gather_genuine_empty_activity_is_zero`, `::test_gather_one_source_fails_others_populated_and_stored`, `::test_gather_activity_source_fails_only_activity_null`, `::test_gather_branch_source_fails_only_branch_active_null` | component | `uv run pytest backend/tests/component/telemetry/test_tasks.py -v -p no:randomly` | 2026-06-29T08:10:25Z | testcontainers Neo4j + MemoryCache + BusSimulator | `7 passed, 16 warnings in 53.54s` | +| `test_datatabase.py::test_gather_database_information_corenode_matches_seeded` | component | `uv run pytest backend/tests/component/telemetry/test_datatabase.py -v -p no:randomly` | 2026-06-28T22:10:20Z | testcontainers Neo4j (2026.05.0-enterprise) | `4 passed, 16 warnings in 22.63s` | + +No E2E tests are part of this feature (producer-only backend; no UI). No row is `MISSING`. + +## 5. Review findings + +| Severity | File | Finding | Disposition | +|----------|------|---------|-------------| +| 🔴 Medium-High | `telemetry/task_manager.py` | `count_webhook_runs` used `len(read_flow_runs(...))`, which caps at the Prefect server default page size (`PREFECT_API_DEFAULT_LIMIT = 200`) — a deployment with >200 webhook successes/day would silently report exactly 200. | **Fixed inline** in `829197d1a` — switched to `client.count_flow_runs(...)` (exact, unpaginated). Webhook tests + full suite re-run green. | +| 🟡 Low (verify) | `telemetry/task_manager.py` | `count_windowed_unique_resources` returns `len(buckets)` from `/events/count-by/resource`; if that endpoint caps the number of buckets returned, `unique_logins` could undercount on very-high-cardinality days. (count-by/**event** is safe — it returns a server-side aggregate `count`, not a list length.) | **Deferred** — lower confidence, and the metric is an explicit best-effort trend signal. Worth confirming the count-by/resource bucket limit before relying on `unique_logins` at scale. | +| 🟡 Low (doc) | spec/contract/data-model/research | Docs list webhook failure as `FAILED`/`CRASHED`/`TIMEDOUT`, but `TIMEDOUT` is not a Prefect 3.7.5 `StateType`; the code correctly uses `FAILED`+`CRASHED`. | **Deferred doc fix** — implementation is correct; the design docs should drop `TIMEDOUT` to match reality. | +| 🔵 Observation | `telemetry/database.py` (corenode) | Live simulation showed `corenode` excludes account **groups** (`get_labels()` doesn't apply the `CoreNode` label to group-generic nodes). This is faithful to the documented "CoreNode-generic" definition, not a bug — but if the product wants groups counted as "managed nodes," that is a definitional choice tied to the parked `user` metric (IFC-2825). | **No change** — surfaced for product awareness. | + +## 6. Autonomous decisions + +- **Dirty-tree handling**: the working tree had many pre-existing unrelated changes. With user approval, every subagent committed by **explicit path only** (never `git add -A`); verified after every chunk that the feature diff stayed within `telemetry/**`, `tests/**/telemetry/**`, `changelog/`, and the spec dir. No unrelated file was committed. +- **Chunking**: one chunk per `tasks.md` phase (8 chunks); US1 kept as a single 9-task chunk (tightly coupled windowed-path work in 3 files). +- **Chunk-4 evidence gap**: that subagent omitted its mandatory evidence block; rather than accept the claim, the orchestrator re-ran the two tests itself to capture verbatim evidence. +- **No-mock resilience seam (US4)**: accepted the optional-injection compromise (3 injected callables with prod defaults) over a larger DI refactor, per the backend-component-design "existing code" exception. +- **Review fix applied inline** (webhook count) rather than deferred, given it's a real accuracy bug with a small localized fix. +- **`speckit-review-run` / `speckit-critique-run` not installed** → review performed directly on the committed diff. +- **Simulation** delivered as a removable demo (kept in scratchpad, not committed) — it hardcodes a scratchpad path and would add ~46s to the component suite, so it does not belong in the committed test set. + +## 7. Suggested next steps + +1. **Resolve the governance gate (T030)** — confirm with the cloud-processor + data-mart owners (the one open item; gates merge/release). +2. **Decide the two deferred review findings**: (a) verify the count-by/resource bucket limit for `unique_logins` at scale; (b) drop `TIMEDOUT` from the design docs to match the implementation. +3. **Open a PR** from `telemetry-collection-infp-589` once T030 is confirmed. +4. Optionally fold the local-collection simulation into a committed smoke test if a repeatable end-to-end demo is wanted (parameterize the scratchpad path first). diff --git a/dev/specs/telemetry-collection-infp-589/plan.md b/dev/specs/telemetry-collection-infp-589/plan.md new file mode 100644 index 00000000000..6734eb47763 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/plan.md @@ -0,0 +1,128 @@ +# Implementation Plan: Phase 1 Telemetry Collection + +**Branch**: `telemetry-collection-infp-589` | **Date**: 2026-06-28 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/telemetry-collection-infp-589/spec.md` + +## Summary + +Extend Infrahub's daily anonymous telemetry payload with additive, backwards-compatible +metrics: account adoption (`accounts.active`, `accounts.groups`), open-branch count +(`branches.active`), a branch/temporal-correct managed-node count +(`database.node_count.corenode`), and a new calendar-day-windowed `activity_24h` object +(`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, +`branches_created/merged/deleted`, `webhooks_fired_success`, `webhooks_fired_failure`). The +check/artifact/branch-lifecycle metrics are derived from events that already flow today, +harvested cheaply on the same windowed path. + +Technical approach: add the new fields to the existing Pydantic payload models; add gather +functions that use the standard branch-safe `NodeManager.count` path for node-based metrics +and a NEW windowed Prefect query path for event/flow-run metrics (the existing unwindowed +`gather_prefect_events` output is left untouched). Bump `TELEMETRY_VERSION`. Introduce a +per-metric graceful-degradation wrapper so a single failing source yields `null` for that +field while the rest of the payload is still gathered, stored, and sent; a source that +succeeds with nothing to count yields `0`. + +## Technical Context + +**Language/Version**: Python 3.14 + +**Primary Dependencies**: Pydantic 2.12 (payload models), Prefect (flow orchestration + +events/flow-run API), Neo4j driver 6.2 (via `InfrahubDatabase` / `NodeManager`) + +**Storage**: Neo4j (node/branch counts via `NodeManager.count`); Prefect event & flow-run +store (24h activity metrics — the graph DB is not an event log, per ADR 0002) + +**Testing**: pytest 9.0 — component tests (TestContainers) for DB-backed counts and the +gather flow; unit tests for windowing/degradation logic where no DB is required + +**Target Platform**: Linux server (Infrahub backend task-worker) + +**Project Type**: Web service backend (single backend package; no frontend work this phase) + +**Performance Goals**: Daily batch job; each metric is a single aggregate query +(`NodeManager.count` / Prefect count-by). No per-node iteration, no N+1. + +**Constraints**: Additive only — no existing field changes meaning/type/name. Per-metric +isolation: one failing source must not drop the payload. Event metrics must reflect exactly a +24h window anchored to a deterministic calendar boundary (previous full UTC day), not to +job-execution time, so daily snapshots tile with no overlap/gap despite the jittered cron. + +**Scale/Scope**: ~16 new payload fields across 2 new sub-models + 2 extended sub-models; +~3 new gather functions; 1 windowed-event counter (reused for logins + 8 check/artifact/branch +metrics); 1 degradation helper; 1 constant bump. Producer-only. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Gate | Status | +|-----------|------|--------| +| I. Schema-Driven Integrity | No schema/generated-file edits; reads through schema-aware `NodeManager`. | ✅ Pass — read-only, no schema changes. | +| II. Branch-Safe by Default | Node/account counts run on the default branch through the standard `NodeManager.count` path (branch + temporal filters applied). Raw `count_nodes(label=...)` is explicitly avoided for `corenode`. | ✅ Pass. | +| III. Type Safety & Explicit Contracts | New Pydantic models with explicit `int \| None` fields; gather functions fully type-hinted; `str \| None` style. | ✅ Pass. | +| IV. Test Discipline | Component tests for SC-001/002/003; tests mirror source under `tests/component/telemetry/` and `tests/unit/telemetry/`; adapter/fixture patterns, no `unittest.mock`. | ✅ Pass. | +| V. Query Performance & Efficiency | Each metric = one aggregate query; no N+1; windowed Prefect queries bounded to 24h. | ✅ Pass. | +| VI. Security & Input Boundaries | No new user input; telemetry is anonymous and opt-out-aware; no secrets; existing endpoint/auth untouched. | ✅ Pass. | +| VII. Simplicity & Maintainability | No new entities, no new dependencies; one small degradation helper justified by ≥2 callers; follows existing `telemetry/*.py` gather-module pattern. | ✅ Pass. | + +**Initial gate: PASS.** No violations; Complexity Tracking left empty. + +## Project Structure + +### Documentation (this feature) + +```text +specs/telemetry-collection-infp-589/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── telemetry-payload.md # Payload contract (new/changed fields + degradation rules) +└── tasks.md # Phase 2 output (/speckit-tasks — not created here) +``` + +### Source Code (repository root) + +```text +backend/infrahub/telemetry/ +├── constants.py # BUMP TELEMETRY_VERSION +├── models.py # ADD TelemetryAccountData, TelemetryActivity24hData; +│ # EXTEND TelemetryBranchData (+active), node_count value type; +│ # ADD accounts + activity_24h to TelemetryData +├── tasks.py # ADD gather_account_information, branches.active wiring, +│ # degradation helper; wire accounts/activity_24h into +│ # gather_anonymous_telemetry_data +├── database.py # ADD node_count["corenode"] via NodeManager.count (degradable) +└── task_manager.py # ADD windowed event path + gather_activity_24h + # (leave gather_prefect_events untouched) + +backend/tests/ +├── component/telemetry/ +│ ├── test_datatabase.py # EXTEND: corenode count correctness (SC-003) +│ ├── test_task_manager.py # EXTEND: 24h windowing (SC-002), unique_logins, webhooks +│ └── test_tasks.py # NEW: gather flow presence + degradation (SC-001) +└── unit/telemetry/ + └── test_degradation.py # NEW: null-on-failure vs 0-on-empty helper logic +``` + +**Structure Decision**: Single backend package, extending the existing `telemetry/` module. +Each metric source keeps its home: DB counts in `database.py`, Prefect/event metrics in +`task_manager.py`, account counts + orchestration + degradation in `tasks.py`. This mirrors +the current separation and adds no new top-level structure (Constitution VII). + +## Complexity Tracking + +> No constitution violations. Section intentionally empty. + +## Phase Notes + +- **Governance gate (GR-001)**: Before merge/release, confirm with the cloud-processor and + data-mart owners that the `payload_format` bump + new fields are tolerated (consumer + ignores unknown fields). This is a release checklist item carried into `tasks.md`, not a + code dependency — every change is additive. +- **`node_count.user` (IFC-2825) in scope**: pulled into Phase 1 once the namespace boundary + was resolved (count user-defined-namespace kinds via the `RESTRICTED_NAMESPACES` negative + filter — excludes `Core` incl. pipeline validators/checks, and `Builtin`). Delivered + alongside `corenode`; the three node metrics nest `user ⊆ corenode ⊆ total`. diff --git a/dev/specs/telemetry-collection-infp-589/quickstart.md b/dev/specs/telemetry-collection-infp-589/quickstart.md new file mode 100644 index 00000000000..7d7277cea6f --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/quickstart.md @@ -0,0 +1,83 @@ +# Quickstart / Validation Guide: Phase 1 Telemetry Collection + +How to validate the feature end-to-end. Implementation details live in `tasks.md`; this is a +run/verify guide. + +## Prerequisites + +```bash +uv sync --all-groups +export DOCKER_HOST=unix://$HOME/.docker/run/docker.sock # component tests need the user docker socket +``` + +## 1. Unit: degradation helper (fast, no DB) + +Validates `null`-on-failure vs `0`-on-empty in isolation (SC-001). + +```bash +uv run pytest backend/tests/unit/telemetry/test_utils.py -q +``` + +Expected: a failing metric coroutine yields `None`; a succeeding-but-empty coroutine yields `0`. + +## 2. Component: managed-node count exactness (SC-003) + +```bash +uv run pytest backend/tests/component/telemetry/test_database.py -q +``` + +Expected: with a fixture of N managed nodes seeded via existing schema helpers, +`node_count["corenode"]` equals N exactly (±0), and `node_count["total"]` (raw vertices) is +unchanged and ≥ N. + +## 3. Component: 24h windowing + activity metrics (SC-002) + +```bash +uv run pytest backend/tests/component/telemetry/test_task_manager.py -q +``` + +Expected: +- `account.logged_in` events seeded inside the trailing 24h are counted; out-of-window events + are not. +- `unique_logins` collapses multiple logins from the same account to one. +- `webhook-process` flow runs in-window split correctly into success/failure; the existing + unwindowed `prefect.events.*` output is unchanged. + +## 4. Component: full gather flow presence + degradation (SC-001) + +```bash +uv run pytest backend/tests/component/telemetry/test_tasks.py -q +``` + +Expected: +- The gathered payload contains `accounts.{active,groups}`, `branches.active`, + `database.node_count.corenode`, and `activity_24h.{logins,unique_logins,webhooks_fired_success,webhooks_fired_failure}`. +- When one source is made to fail (injected failing collaborator/fixture — no `unittest.mock`), + that field is `null`, every other field is populated, and the payload is still built/stored. + +## 5. Whole telemetry suite + lint + +```bash +uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q +uv run invoke format lint +``` + +## 6. Manual payload inspection (optional) + +Trigger the daily flow in a dev stack and inspect a stored snapshot to confirm +`payload_format == "20260628"` and the new fields are present with sensible values. + +## 7. Governance gate (GR-001) — before merge/release + +Confirm with the cloud-processor owner and the data-mart owner that the `payload_format` bump +and new fields are tolerated (consumer ignores unknown fields). Record the confirmation on the +PR / tracking ticket. No code change depends on it (additive), but it gates release. + +## Success criteria mapping + +| Criterion | Validated by | +|-----------|--------------| +| SC-001 (presence + null-vs-0) | Steps 1, 4 | +| SC-002 (exact 24h window) | Step 3 | +| SC-003 (corenode exact) | Step 2 | +| SC-004 (additive, format bump, consumer-safe) | Step 6 + Step 7 | diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md new file mode 100644 index 00000000000..2cd272a948f --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -0,0 +1,272 @@ +# Phase 0 Research: Phase 1 Telemetry Collection + +All technical context was grounded by reading the existing telemetry module and its +collaborators. No open `NEEDS CLARIFICATION` items remain. + +## Decision 1 — Branch/temporal-correct managed-node count (`corenode`) + +**Decision**: Compute `database.node_count["corenode"]` with +`NodeManager.count(db=..., schema=InfrahubKind.NODE, branch=)`. `InfrahubKind.NODE` +== `"CoreNode"`, the generic every managed node inherits. + +**Rationale**: `NodeManager.count` builds `NodeGetListQuery` which applies branch + temporal +filters (Constitution II). The existing `gather_database_information` uses raw +`utils.count_nodes(db, label=...)` over `GRAPH_SCHEMA["nodes"]` — those are graph-internal +labels (`Node`, `Attribute`, …) and produce raw vertex tallies with no branch/temporal +correctness. `node_count["total"]` is the raw `count_nodes(db)` vertex total and must stay +as-is (FR-009). The key `corenode` does not collide with any `GRAPH_SCHEMA["nodes"]` label, +so it is a clean additive key. + +**Namespace semantics (locked, so `corenode` and the future `user` can never become +synonyms)**: `get_labels()` (`core/node/__init__.py`) applies the `CoreNode` label to every +node whose namespace is **not** `Schema`/`Internal` and which is not a group. So +`NodeManager.count(CoreNode)` counts the **`Core` + `Builtin` + user-defined namespaces** — +including Infrahub's own management objects (`CoreAccount`, `CoreRepository`, +`CoreProposedChange`, `CoreWebhook`, profiles, resource pools, artifacts, …). The three node +metrics therefore nest strictly: + +``` +user ⊆ corenode ⊆ total +``` + +- `total` — raw vertices (incl. attributes/values/internal bookkeeping). +- `corenode` — **all** managed nodes across `Core` + `Builtin` + user namespaces (this phase). + Note this includes the `Core`-namespace **pipeline validators/checks** created per proposed + change, so `corenode` can be inflated by pipeline activity — a total-managed-footprint number. +- `user` — customer-facing subset in **user-defined namespaces only** (this phase; see decision + below). Excludes `Core` (incl. the pipeline validators/checks) and `Builtin`. + +**`user` definition (decided per Patrick's guidance; IFC-2825 resolved).** Compute `user` as the +sum of `NodeManager.count` over the concrete node kinds whose namespace is **user-editable**, +i.e. `namespace not in RESTRICTED_NAMESPACES` (`Account`, `Branch`, `Builtin`, `Core`, +`Deprecated`, `Diff`, `Infrahub`, `Internal`, `Lineage`, `Schema`, `Profile`, `Template`). The +schema branch already exposes this: `SchemaNamespace.user_editable == (namespace not in +RESTRICTED_NAMESPACES)`, so `get_namespaces()` + `get_schemas_for_namespaces()` yield the +user-defined kinds without re-deriving the filter. This negative filter naturally drops the +`Core`-namespace pipeline validators/checks (the high-volume ephemeral nodes Patrick flagged as +noise) and, being restricted, `Builtin` — so `BuiltinTag` is **not** counted (matches current +product direction; revisit if tags move to user-defined schemas). Group-generic kinds are +excluded (no `CoreNode` label), preserving `user ⊆ corenode`. Rationale for summing per-kind +(vs a single query): `NodeManager.count` is the branch/temporal-correct path (Constitution II); +the number of user kinds is small and this runs once daily. + +This pins the definitions at the namespace level so a later `user` definition cannot +accidentally equal `corenode` — a real concern given FR-011 forbids removing a shipped field. + +**Alternatives considered**: `count_nodes(label="CoreNode")` — rejected: no branch/temporal +filter, would diverge from how GraphQL resolvers count and would not match an +independently-computed fixture (fails SC-003). + +## Decision 2 — Account & branch metrics + +**Decision**: +- `accounts.active` = `NodeManager.count(db, schema=InfrahubKind.ACCOUNT, filters={"status__value": AccountStatus.ACTIVE.value}, branch=)`. +- `accounts.groups` = `NodeManager.count(db, schema=InfrahubKind.ACCOUNTGROUP, branch=)`. +- `branches.active` = count of `registry.branch.values()` where `not is_default and not is_global`. + +**Rationale**: `NodeManager.count` is the same path the account GraphQL resolver uses +(grounded in `graphql/queries/account.py`). `CoreAccount.status` is an enum attribute, so the +`status__value` filter selects `AccountStatus.ACTIVE`. The registry already holds all open +branches; `branches.total` today is `len(registry.branch)`. `is_default` marks `main`, +`is_global` marks `-global-` (`GLOBAL_BRANCH_NAME`), so excluding both yields open +non-system branches. Closed/merged/deleted branches are removed from the registry, so +"registry membership" already means "open". + +**Alternatives considered**: Querying branches from the DB — rejected: the registry is the +in-memory source of truth used elsewhere and avoids an extra query (Constitution V, VII). + +## Decision 3 — NEW 24h-windowed Prefect event path (logins, unique_logins) + +**Window anchor (decided)**: The 24h window is anchored to a **deterministic calendar +boundary, NOT to `datetime.now()` at gather time**. Compute: + +``` +window_end = floor_to_midnight_utc(now) # 00:00:00 UTC of the current day +window_start = window_end - 24h # 00:00:00 UTC of the previous day +``` + +so each daily run reports the **previous full UTC calendar day** `[window_start, window_end)`. + +Rationale: the daily flow is scheduled `f"{random.randint(0, 59)} 2 * * *"` +(`workflows/catalogue.py`) — a per-deployment-fixed minute, firing at 02:XX. Anchoring the +window to execution `now` is fragile: gather time drifts day-over-day (worker contention, the +flow's own retries, queue latency), so consecutive `[now-24h, now]` windows either **overlap** +(events double-counted) or leave a **gap** (events counted in neither run). SC-002 explicitly +requires "no retention leakage/overlap", so the window must tile exactly. Flooring to midnight +UTC makes the daily series tile perfectly regardless of the random minute or execution jitter; +because the job runs at 02:XX, `window_end` (00:00 today) is always 2-3h settled in the past, +so every prior-day event has landed. A missed run simply yields an absent day rather than a +smeared one, and tests can pin a real boundary with `freezegun` instead of a moving `now`. The +field name `activity_24h` is retained (it is a 24h window); only the anchor is fixed. + +**Decision**: Add a new windowed counter that posts to `/events/count-by/event` with an +`occurred` window (`since = window_start`, `until = window_end` as defined above) in the +filter, alongside the existing `event.name` filter. For `account.logged_in`: +- `activity_24h.logins` = the windowed count of `infrahub.account.logged_in` events. +- `activity_24h.unique_logins` = distinct accounts in the same window, obtained by counting + by **resource** (`/events/count-by/resource`) over the windowed `logged_in` filter and + taking the number of buckets. Each login event's `prefect.resource.id` is + `infrahub.account.{account_id}` (grounded in `AccountLoggedInEvent.get_resource`), so one + bucket per distinct account ⇒ bucket count = unique logins. + +The existing `gather_prefect_events` (no time window) is **left untouched** (FR-007); the new +path is separate functions feeding `activity_24h`. + +**Boundary inclusivity (found via live multi-day simulation)**: Prefect's `EventOccurredFilter` +treats both `since` and `until` as **inclusive** (`since <= occurred <= until`), and the +flow-run `start_time.before_` is likewise "at or before". Passing `until = window_end` would +therefore count an event stamped exactly at midnight in **two consecutive daily windows** +(observed: a 4-day tiling simulation summed 14 counts over 12 seeded events). Both windowed +paths pull the upper bound back by one microsecond so the effective interval is the documented +half-open `[start, end)`; an exact-boundary event now lands in exactly one window (regression +test seeds events at exactly `window_start` — counted — and exactly `window_end` — excluded). + +**Rationale**: Logins are events stored in Prefect (ADR 0002); Neo4j has no `last_login`. The +event name is `infrahub.account.logged_in` (`AccountLoggedInEvent.event_name`). The 24h +window (≪ 7-day event retention) guarantees no retention leakage when the `occurred` filter +is applied (SC-002). Counting by resource id is the natural distinct-count primitive without +pulling every event. + +**Alternatives considered**: (a) modify `gather_prefect_events` to add a window — rejected by +FR-007 (must not change existing output). (b) pull all events and de-dup in Python — +rejected: heavier, and Prefect's count-by primitives do it server-side (Constitution V). + +## Decision 4 — Webhook success/failure over 24h + +**Decision**: `activity_24h.webhooks_fired_success` / `_failure` come from Prefect **flow +runs** of the `webhook-process` flow (grounded: `@flow(name="webhook-process")` in +`webhook/tasks/process.py`) started within the **same `[window_start, window_end)` calendar-day +window as the event metrics** (Decision 3 anchor — not `now`), split by terminal state: +`COMPLETED` ⇒ success; `FAILED` / `CRASHED` ⇒ failure (`TIMEDOUT` is not a Prefect `StateType`). +Query via the Prefect +client's flow-run read API filtered by flow name and `start_time` in `[window_start, window_end)`. + +**Rationale**: Webhook delivery is a flow run, not an InfrahubEvent, so flow-run state is the +correct signal. Webhook flow-run retention is 90 days (≫ 24h), so the window is always fully +covered. Counts are best-effort trend signals (dispatch can drop), framed against windowing +correctness, not an external ground truth. + +**Alternatives considered**: Deriving from events — rejected: webhook outcome lives in the +flow-run state, not an event. + +## Decision 5 — Graceful degradation contract (`null` vs `0`) + +**Decision**: New payload fields are `int | None`. Introduce a single async helper in +`tasks.py` that runs a metric coroutine, returns its value on success, and on any exception +logs a warning and returns `None`. The orchestrator (`gather_anonymous_telemetry_data`) +gathers each new metric through this helper, so one failing source ⇒ that field `null`, the +rest of the payload still built, stored, and sent. A source that succeeds with nothing to +count returns `0` naturally (e.g. `NodeManager.count` ⇒ 0, empty window ⇒ 0). + +**Rationale**: FR-010 / SC-001 require per-metric isolation and a `null`-means-failure, +`0`-means-empty convention. A single helper serving ≥2 callers is the justified extraction +(Constitution VII). Existing required fields are **not** widened to optional (FR-011) — only +the new fields are nullable. + +**`node_count` value-type note**: `node_count` is currently `dict[str, int]`. To let +`corenode` be `null` on failure while keeping it inside `node_count` (the field name the +contract mandates), the value type widens to `dict[str, int | None]`. This is additive in +practice: existing keys (`total`, graph labels) are always populated `int`; only the new +`corenode` key can be `null`. A forward-compatible consumer is unaffected. Documented as an +accepted, additive type-widening rather than a meaning/name change (FR-011 honored). + +**Alternatives considered**: Wrapping the whole gather in one try/except — rejected: a single +failure would null unrelated metrics, violating per-metric isolation. + +## Decision 6 — `payload_format` bump + +**Decision**: Bump `TELEMETRY_VERSION` in `constants.py` from `"20250318"` to a new date +string (`"20260628"`). `DEFAULT_PAYLOAD_FORMAT` follows it. The value flows into both the +stored snapshot and the remote payload (`payload_format` key) already. + +**Rationale**: FR-007 requires advancing the format identifier when fields are added. The +codebase already uses a `YYYYMMDD` convention. + +## Decision 7 — Test strategy (grounded) + +**Decision**: +- **SC-003 (corenode)** — component test in `test_datatabase.py`: seed a known number of + managed nodes via existing schema fixtures, independently compute the expected count, assert + `node_count["corenode"]` matches exactly (±0). +- **SC-002 (windowing)** — component/unit test in `test_task_manager.py`: emit `logged_in` + and `webhook-process` records inside and outside the 24h window; assert in-window-only + counts and that `unique_logins` collapses repeat logins per account. +- **SC-001 (presence + degradation)** — new `test_tasks.py`: run the gather flow; assert all + in-scope fields present; force one source to fail (via an injected failing adapter/fixture, + not `unittest.mock`) and assert that field is `null`, others populated, payload still built; + assert genuine-empty ⇒ `0`. + +**Rationale**: Aligns with Constitution IV and `testing-python.md` (no mocking; component +tests via TestContainers; adapter/fixture injection; files mirror source). Existing +`prefect_test_fixture` and telemetry component fixtures are reused. + +**No-mock seam (decided, not open)**: The `null`-vs-`0` contract is proven WITHOUT any +`unittest.mock` by two complementary, decided approaches: + +1. **Degradation helper unit test** (`test_degradation.py`): the helper takes a coroutine and + returns its value or `None` on exception. Pass it (a) a coroutine that `raise`s → assert + `None`; (b) a coroutine returning `0` → assert `0`; (c) a coroutine returning `N` → assert + `N`. No DB, no mock — a plain failing/succeeding coroutine is the test double. +2. **Flow presence test** (`test_tasks.py`): assert every in-scope field is present in the + gathered payload on a healthy stack. End-to-end "one source nulled, rest populated" is + covered by composing the helper (proven in #1) with the orchestrator wiring; if a natural + failing-source fixture is cheap (e.g. pointing a gather at an absent Prefect resource) it is + added, but the contract does NOT depend on mocking a failure end-to-end. + +**Deterministic time (decided)**: the 24h-window tests (SC-002) use `freezegun` to pin "now" +(an explicitly allowed exception in `testing-python.md` for time-dependent behavior), so +in-window vs out-of-window fixtures are unambiguous and non-flaky. + +**Prefect `.fn` + logger**: where a `@task`/`@flow`-decorated function is exercised via `.fn` +outside a flow context, `get_run_logger` is handled per the allowed `testing-python.md` +pattern (return a stdlib logger), not via general mocking. + +## Decision 9 — Phase split by "is the event already flowing?" (checks & artifacts pulled in) + +**Decision**: Once the windowed event path exists (Decision 3), any metric derived from an +**already-emitted, already-counted** event costs ~one event name + a parametrized test. So the +Phase 1/2 boundary for event-derived metrics is drawn on *"is the event already flowing and is +a raw windowed count the valuable signal?"* — not on the original card's labelling. Verified +against `get_all_events()`: + +- **Pulled into Phase 1** (events emitted & counted today; raw per-period count *is* the + depth-of-adoption signal): `validator.started/passed/failed` → `checks_*`; + `artifact.created/updated` → `artifacts_*`; `branch.created/merged/deleted` → `branches_*`. + Near-zero marginal cost, serves a stated Phase 1 goal. Branch lifecycle counts need no + correlation — only branch *lifetime* (duration) does. +- **Held in Phase 2 although the events exist** (a bare count would be permanent contract + surface per FR-011 without clear standalone Phase 1 value): + - PR governance — `proposed_change.*` exist, but "merged without review" needs per-PR + review↔merge correlation. + - Branch *lifetime* — the create→merge duration needs durable per-branch correlation (the + lifecycle *counts* are pulled in above; only the duration is deferred). + - Node churn — `node.created/updated/deleted` exist, but `node.updated` fires on every + attribute mutation incl. automated/computed writes, so the count is machine-dominated — a + noisy adoption proxy, not a clean signal. (Held on signal quality, not cost.) + - Branch `rebased`/`migrated` counts — maintenance/automation-driven, lower-signal than + create/merge/delete; deferred to keep the permanent field set focused. +- **Stay Phase 2 — no events at all**: generators/transformations (no `generator.*`/`transform.*` + events), distinct API tokens (no token identity in events), CLI/MCP/Sync (greenfield SDK + instrumentation), licensing cores/RAM (product-scope decision). + +**Rationale**: This keeps Phase 1 disciplined (every field must serve a stated goal, not just +be cheap) while harvesting the genuine free wins the windowing enabler unlocks. The discipline +matters because FR-011 makes every shipped field unremovable. + +**Scope note (divergence from handoff PRD)**: checks/artifacts and branch-lifecycle counts +were not in the handoff PRD's in-scope FR list; they are a deliberate, user-directed expansion +recorded in `alignment-check.md` §6. The events being verified-present is what makes the +expansion safe. + +## Decision 8 — Webhook run terminality & count cost (secondary) + +**Webhook non-terminal runs**: a `webhook-process` run that started in-window but is still +`PENDING`/`RUNNING`/`SCHEDULED` at gather time is counted as neither success nor failure. This +is correct for a best-effort daily trend signal — only terminal outcomes are tallied. Captured +in the contract and data model. + +**`corenode` count cost (Constitution V)**: `NodeManager.count(CoreNode)` is a single aggregate +query (order disabled) over the managed-node generic with branch/temporal filters — no N+1, no +node materialization. On very large deployments this is still a full count; it runs once per +day in a batch job, so the cost is acceptable. A benchmark is not required for this phase but +the single-aggregate shape is a deliberate choice over per-label summation. diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md new file mode 100644 index 00000000000..597b13defa7 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -0,0 +1,409 @@ +# Feature Specification: Phase 1 Telemetry Collection + +**Feature Branch**: `telemetry-collection-infp-589` + +**Created**: 2026-06-28 + +**Status**: Draft + +**Input**: User description: "Phase 1 telemetry collection (epic IFC-2789, idea INFP-589). Extend Infrahub's daily telemetry payload with additive, backwards-compatible metrics for 1.11. Producer-only: add fields to the emitted/stored payload; dashboard rendering is out of scope (INFP-550 / SA-184). EXCLUDE user_node_count (blocked on a product decision — IFC-2825)." + +## Overview + +Infrahub emits an anonymous telemetry payload on a daily schedule. Today that +payload captures a handful of coarse counts (total branches, raw vertex count, +unwindowed Prefect event tallies). The product and data teams cannot answer +basic adoption questions from it — how many accounts are active, how the +deployment is scaling in domain terms, or what happened in the last day. + +This feature extends the daily payload with a set of **additive, +backwards-compatible** metrics so the receiving data mart gains usable adoption +and scaling signals. It is **producer-only**: it changes what Infrahub emits and +stores, not how anyone visualizes it. Dashboard work (INFP-550 / SA-184) is a +separate, downstream effort. + +The work is deliberately scoped as Phase 1 of a larger telemetry roadmap (epic +IFC-2789). Phase 2 metrics (licensing, token usage, generator/transformation +adoption, branch lifetime, PR governance, CLI/MCP/Sync adoption, GraphQL/REST +metrics) are explicitly **not** in scope here. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Daily activity signal over a trailing 24h window (Priority: P1) + +As the OpsMill data team, I need each daily telemetry payload to carry an +`activity_24h` object describing what happened in the trailing 24 hours — +logins, unique logins, and webhook successes/failures — so I can observe usage +trends per deployment instead of meaningless lifetime totals. + +**Why this priority**: This is the enabler. It introduces the new windowed +event-query path, the `activity_24h` object, the `payload_format` bump, and the +graceful-degradation behavior that every other metric depends on. Without it the +other metrics have no consistent failure contract and no precedent for windowed +queries. It also delivers the highest-value signal: real daily activity. + +**Independent Test**: Seed a deployment with login and webhook-process events, +some inside the trailing 24h window and some outside it, then trigger the daily +gather. The emitted payload contains an `activity_24h` object whose counts +reflect exactly the in-window events and ignore the out-of-window ones. + +**Acceptance Scenarios**: + +1. **Given** a deployment with login events both inside and outside the trailing + 24h window, **When** the daily telemetry payload is gathered, **Then** + `activity_24h.logins` equals the count of in-window login events only. +2. **Given** logins from a set of distinct accounts within the window (some + accounts logging in multiple times), **When** the payload is gathered, + **Then** `activity_24h.unique_logins` equals the number of distinct accounts, + not the number of login events. +3. **Given** webhook-process runs in the last 24h with a mix of successes and + failures, **When** the payload is gathered, **Then** + `activity_24h.webhooks_fired_success` and `activity_24h.webhooks_fired_failure` + reflect exactly the in-window successful and failed runs respectively. +4. **Given** a deployment with zero activity in the window, **When** the payload + is gathered, **Then** each `activity_24h` count is `0` (not `null`, not + absent). + +--- + +### User Story 2 - Account and branch adoption metrics (Priority: P2) + +As the OpsMill data team, I need the payload to report the number of active +accounts, account groups, and currently-open non-system branches, so I can +gauge real adoption per deployment. + +**Why this priority**: These are high-value adoption signals computed through +the standard branch-safe count path. They depend on the graceful-degradation +contract established in Story 1 but are otherwise independent. + +**Independent Test**: Seed a deployment with a known mix of active/inactive +accounts, account groups, and open/closed/system branches, trigger the gather, +and assert each reported count matches the seeded fixture exactly. + +**Acceptance Scenarios**: + +1. **Given** a deployment with active and non-active accounts, **When** the + payload is gathered, **Then** `accounts.active` equals the count of accounts + whose status is active. +2. **Given** a known number of account groups, **When** the payload is gathered, + **Then** `accounts.groups` equals that count. +3. **Given** open branches including the default and the global system branch, + **When** the payload is gathered, **Then** `branches.active` counts the open + branches while excluding the default branch and the global system branch. +4. **Given** the existing `branches.total` field, **When** the payload is + gathered, **Then** `branches.total` is unchanged in meaning, type, and name. + +--- + +### User Story 3 - Branch-correct scaling metric for managed nodes (Priority: P2) + +As the OpsMill data team, I need a node count that reflects the number of +schema-managed nodes (the `CoreNode` generic) computed the same way the product +counts nodes, so I can measure how a deployment scales in domain terms — distinct +from the raw vertex total that includes internal graph bookkeeping. + +**Why this priority**: Scaling is a primary telemetry question and the raw vertex +total is misleading for it. This metric must be computed through the +branch-safe, temporal-correct count path rather than a raw label count. + +**Independent Test**: Build a fixture with a known number of managed nodes, +independently compute the expected count, trigger the gather, and assert +`database.node_count.corenode` matches the independently-computed value exactly +(±0). + +**Acceptance Scenarios**: + +1. **Given** a deployment with a known number of managed (`CoreNode`-generic) + nodes, **When** the payload is gathered, **Then** + `database.node_count.corenode` equals that count exactly. +2. **Given** the existing `database.node_count.total` raw-vertex field, **When** + the payload is gathered, **Then** `database.node_count.total` is unchanged in + meaning, type, and name, and the distinction between the raw total and the + managed-node count is documented. + +--- + +### User Story 4 - Resilient payload that never silently drops everything (Priority: P1) + +As the OpsMill data team, I need the daily payload to keep arriving even when one +metric source fails, with a clear convention distinguishing "source failed" from +"genuinely zero", so I can trust field presence and interpret nulls correctly. + +**Why this priority**: This is the reliability contract that makes the data +usable. Without it, one failing query could drop the whole payload, or a `null` +could ambiguously mean either "failed" or "none", corrupting trend analysis. + +**Independent Test**: Force one metric's source to fail while leaving the others +healthy, trigger the gather, and assert the failed metric is `null`, every other +field is populated, and the payload is still sent/stored. + +**Acceptance Scenarios**: + +1. **Given** one metric source that raises an error during gathering, **When** + the payload is gathered, **Then** that metric's field is `null`, all other + fields are populated, and the payload is still emitted and stored. +2. **Given** a metric source that succeeds but legitimately has nothing to count, + **When** the payload is gathered, **Then** that metric's field is `0`, not + `null`. +3. **Given** any version of the new payload, **When** it is emitted, **Then** the + `payload_format` identifier reflects the new payload version. + +--- + +### User Story 5 - Depth-of-adoption activity: checks, artifacts & branch lifecycle (Priority: P2) + +As the OpsMill data team, I need the daily payload to report how many validation +checks ran (and their pass/fail outcomes), how many artifacts were generated, and +how many branches were created / merged / deleted over the same trailing-24h +window, so I can measure *depth* of adoption — not just that a deployment exists, +but that its core workflows (validation, artifacts, and the branch-based change +workflow that is Infrahub's differentiator) are actively used. + +**Why this priority**: These ride entirely on the windowed event path delivered by +User Story 1 — the underlying events (`validator.started/passed/failed`, +`artifact.created/updated`, `branch.created/merged/deleted`) are already emitted and +counted today, so the marginal cost is one additional event name per metric plus a +parametrized test. They serve the depth-of-adoption goal directly, making them a +near-zero-cost extension of the enabler rather than new scope of their own. Branch +*lifecycle counts* need no correlation (unlike branch *lifetime*, which is held to a +later phase). + +**Independent Test**: Seed validator, artifact, and branch events inside and outside +the 24h window, trigger the gather, and assert each count reflects exactly the +in-window events. + +**Acceptance Scenarios**: + +1. **Given** validator events (`started`/`passed`/`failed`) inside the window, + **When** the payload is gathered, **Then** `activity_24h.checks_started`, + `checks_passed`, and `checks_failed` equal the in-window counts of each. +2. **Given** artifact events (`created`/`updated`) inside the window, **When** the + payload is gathered, **Then** `activity_24h.artifacts_created` and + `artifacts_updated` equal the in-window counts of each. +3. **Given** branch events (`created`/`merged`/`deleted`) inside the window, **When** + the payload is gathered, **Then** `activity_24h.branches_created`, + `branches_merged`, and `branches_deleted` equal the in-window counts of each. +4. **Given** a deployment with no such events in the window, **When** the payload + is gathered, **Then** each of these counts is `0` (not `null`, not absent), and + on a source failure the affected field is `null`. + +--- + +### Edge Cases + +- **Event-retention leakage**: The 24h window is far shorter than the underlying + event retention (7 days for events, 90 days for webhook flow runs), so a correct + window must never include retained-but-out-of-window records. +- **Window anchoring vs. jittered schedule**: The daily job runs at a per-deployment + random minute. If the window were anchored to the job's execution time, day-over-day + execution drift would make consecutive windows overlap (double-count) or gap (miss + records). The window MUST therefore be anchored to a fixed calendar boundary (the + previous full UTC day), not to execution time, so daily snapshots tile exactly. +- **Best-effort event counts**: Event dispatch can drop records, so activity + counts are a trend signal, not a billing-grade exact count. This is acceptable + and must be documented; success criteria for event metrics are framed around + windowing correctness, not against an external ground truth. +- **Default and system branches**: `branches.active` must exclude the default + branch and the global system branch; only genuinely open, non-system branches + count. +- **Existing-field protection**: No existing field may change meaning, type, or + name. Deprecation is allowed; removal is not. +- **Consumer compatibility**: A `payload_format` bump plus new fields must be + tolerated by a forward-compatible consumer that ignores unknown fields. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The payload MUST report `accounts.active` as the count of accounts + whose status is active, computed through the standard branch-safe count path. + [IFC-2822] +- **FR-002**: The payload MUST report `accounts.groups` as the count of account + groups. [IFC-2822] +- **FR-003**: The payload MUST report `database.node_count.corenode` as the count + of managed (`CoreNode`-generic) nodes, computed through the branch-safe, + temporal-correct count path (not a raw vertex/label count). [IFC-2821] +- **FR-004**: The payload MUST report `database.node_count.user` as the count of + user/business nodes — nodes in user-defined (non-restricted) namespaces — + computed through the branch-safe count path. This is the customer-facing subset + of `corenode`: it excludes the `Core` management namespace (accounts, + repositories, proposed changes, and the pipeline-generated validators/checks) and, + by the same restricted-namespace rule, the `Builtin` namespace. [IFC-2825] +- **FR-005**: The payload MUST report `branches.active` as the count of open, + non-system branches, excluding the default branch and the global system + branch. [IFC-2822] +- **FR-006**: The payload MUST report `activity_24h.webhooks_fired_success` and + `activity_24h.webhooks_fired_failure` as the counts of successful and failed + webhook-process runs over the trailing 24h. [IFC-2824] +- **FR-007**: The feature MUST add a NEW trailing-24h-windowed event-query path + that feeds `activity_24h`, WITHOUT modifying the existing unwindowed event + tally output, and MUST advance the `payload_format` identifier. [IFC-2820] +- **FR-008**: The payload MUST report `activity_24h.logins` (count of login + events over the trailing 24h) and `activity_24h.unique_logins` (distinct + accounts over the same window). [IFC-2823] +- **FR-009**: The feature MUST NOT change the existing raw-vertex + `database.node_count.total` field, and MUST document the distinction between the + node metrics so they cannot later become synonyms. Definitions are operational, by + how each is computed: + - `total` — raw graph vertex count (includes history, all branches, and internal + bookkeeping nodes). Unchanged by this feature. + - `corenode` — count of nodes carrying the `CoreNode` generic label, obtained via + the branch-safe `NodeManager.count` path (same as FR-003). By construction this + is every schema-managed node whose namespace is not internal-only + (`Schema`/`Internal`); it therefore includes management kinds such as + `CoreAccount` **and the pipeline-generated validators/checks created per proposed + change**, not just user-defined data. Because of the latter, `corenode` can be + inflated by proposed-change pipeline activity — it is a total-managed-footprint + number, not a clean customer-data number (that is what `user` is for). + - `user` — the customer-facing subset of `corenode`: nodes in user-defined + (non-restricted) namespaces. Defined operationally as the sum over node kinds + whose namespace is NOT in the restricted-namespace set, which excludes the `Core` + management namespace (incl. the pipeline validators/checks) and the `Builtin` + namespace. `BuiltinTag` is therefore not counted (it is `Builtin`); this matches + current product direction and is revisited if tags move to user-defined schemas. + + The metrics nest by construction (`user ⊆ corenode ⊆ total`); all three are + defined and delivered in this phase. [IFC-2821, IFC-2825] +- **FR-010**: When a metric's source fails, that field MUST be set to `null` + while the rest of the payload is still emitted and stored; when a source + succeeds with nothing to count, the field MUST be `0`, not `null`. [IFC-2820] +- **FR-011**: All changes MUST be additive. No existing field may change its + meaning, type, or name. Deprecation is permitted; removal is not. +- **FR-012**: The payload MUST report `activity_24h.checks_started`, + `activity_24h.checks_passed`, and `activity_24h.checks_failed` as the windowed + counts of `validator.started`, `validator.passed`, and `validator.failed` events + respectively, via the same windowed event path as FR-007/FR-008. [INFP-589, depth-of-adoption] +- **FR-013**: The payload MUST report `activity_24h.artifacts_created` and + `activity_24h.artifacts_updated` as the windowed counts of `artifact.created` and + `artifact.updated` events respectively, via the same windowed event path. [INFP-589, depth-of-adoption] +- **FR-014**: The payload MUST report `activity_24h.branches_created`, + `activity_24h.branches_merged`, and `activity_24h.branches_deleted` as the windowed + counts of `branch.created`, `branch.merged`, and `branch.deleted` events + respectively, via the same windowed event path. (Branch *lifetime* — create→merge + duration — remains out of scope; it needs per-branch correlation.) [INFP-589, depth-of-adoption] + +### Governance Requirement + +- **GR-001**: Before shipping, the payload contract change (the `payload_format` + bump and the new fields) MUST be confirmed compatible with the receiving end + (the cloud telemetry processor and the downstream data mart) — specifically + that the receiver (a) tolerates the format bump, (b) ignores unknown fields, + and (c) tolerates `null` values on the new fields, including a `null` value on + the new `corenode` key inside the existing `node_count` object (the only place + a previously all-integer map can now carry a `null`). Because every change is + additive, a forward-compatible consumer keeps working; this is a confirmation + gate, not a code dependency. + +### Key Entities *(include if feature involves data)* + +- **Telemetry payload**: The daily anonymous data structure Infrahub emits and + stores. Carries a `payload_format` version identifier and nested sub-objects + (`accounts`, `branches`, `database`, and the new `activity_24h`). +- **`activity_24h` object**: A new sub-object holding activity counts over the + previous full UTC calendar day (a 24h window anchored to a fixed boundary, not + to job-execution time): `logins`, `unique_logins`, `checks_started`, + `checks_passed`, `checks_failed`, `artifacts_created`, `artifacts_updated`, + `branches_created`, `branches_merged`, `branches_deleted`, + `webhooks_fired_success`, + `webhooks_fired_failure`. +- **Node-count metrics (`database.node_count`)**: A map of node counts. `total` = + raw vertices (existing, unchanged); `corenode` = `CoreNode`-generic count via the + branch-safe count path (new); `user` = the customer-facing subset in user-defined + (non-restricted) namespaces, via the branch-safe count path (new). They nest + `user ⊆ corenode ⊆ total`. The map may carry a `null` on either new key + (`corenode`, `user`) per FR-010 / GR-001; existing keys are always populated. +- **Account**: A user account with a status (active vs. non-active) used for + `accounts.active`. +- **Account group**: A grouping of accounts, counted for `accounts.groups`. +- **Branch**: A line of change. The default branch and the global system branch + are excluded from `branches.active`. +- **Login event**: An event emitted after authentication and stored in the event + system (not in the graph database). The source for `activity_24h.logins` and + `unique_logins`. +- **Webhook-process run**: An execution of the webhook delivery flow, with a + success/failure outcome, counted for the `activity_24h` webhook fields. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All in-scope fields are present on 100% of daily telemetry runs; a + field is `null` only when its source genuinely failed, never when the source + succeeded with nothing to count. +- **SC-002**: Event-derived metrics reflect exactly a 24h window anchored to a + deterministic calendar boundary (the previous full UTC day), so consecutive + daily snapshots tile with no overlap and no gap — independent of the exact + (jittered) time the daily job runs — and with no leakage from records retained + but outside the window. Verified with fixtures containing both in-window and + out-of-window records. +- **SC-003**: `database.node_count.corenode` matches an independently-computed + fixture count exactly (±0); `database.node_count.user` counts only user-defined + namespace nodes (a seeded `Core` node is excluded), and the nesting invariant + `user ⊆ corenode ⊆ total` holds. +- **SC-004**: No existing telemetry field changes meaning, type, or name across + this release (the `node_count` map may carry a `null` value only on the new + `corenode` key); the `payload_format` identifier is advanced and a + forward-compatible consumer that ignores unknown fields and tolerates `null` + values continues to parse the payload (confirmed with the receiving team per + GR-001). + +## Assumptions + +- The telemetry feature is producer-only; consuming/visualizing the new fields + (dashboards, INFP-550 / SA-184) is out of scope and handled downstream. +- Login activity is sourced from the event system (events are emitted after + authentication and stored there); the graph database holds no last-login + timestamp, so a windowed event query is the correct and only source. +- The 24h window is anchored to the previous full UTC calendar day (a fixed + boundary, not job-execution time) and is comfortably shorter than the underlying + retention windows (7-day event retention, 90-day webhook flow-run retention), so + all in-window records are available at gather time and consecutive daily + snapshots tile exactly. +- Activity counts are best-effort trend signals (event dispatch can drop), not + billing-grade exact figures; correctness is judged on windowing behavior, not + against an external ground truth. +- Counts are gathered on the default branch through the standard branch-safe + count path, consistent with how the product computes them elsewhere. +- The "graceful degradation" contract (per-metric isolation, `null` on failure, + `0` on genuine empty) applies uniformly to every in-scope metric. + +## Out of Scope + +- Remaining Phase 2 telemetry items: licensing (cores/RAM), distinct API-token + usage, generators/transformations adoption, branch lifetime, PR governance, + CLI/MCP/Sync adoption, and GraphQL/REST metrics. (Checks and artifacts, formerly + considered Phase 2, are pulled into Phase 1 — see FR-012/FR-013 — because their + events already flow and serve a stated Phase 1 goal.) +- **Configured database-core reporting** (`server.threads.worker_count` via + `SHOW SETTINGS`, alongside the existing physical `processor_available`) was + pulled into Phase 1 scope per the Jira card's 1 Jul scope-change note, then + deferred back out during implementation: the candidate setting is REST-only + (doesn't govern the Bolt path Infrahub uses) and defaults to the host core + count, so today it would only duplicate `processor_available`. Revisit once + the correct "licensed cores" setting is confirmed (open dependency on Fatih; + see `data-model.md` for the investigation detail). +- **Held in Phase 2 even though their events already flow** (a bare count would be + permanent contract surface — FR-011 — without clear standalone Phase 1 value): + - **PR governance** — `proposed_change.*` events exist, but the useful metric + ("merged without review") needs per-PR review↔merge correlation. + - **Branch lifetime** — the create→merge *duration* metric needs durable per-branch + correlation. (Branch lifecycle *counts* — created/merged/deleted — are in scope as + `activity_24h.branches_*`, FR-014; only the duration is deferred.) + - **Node churn** — `node.created/updated/deleted` events exist, but `node.updated` + fires on every attribute mutation incl. automated/computed writes, so the count is + dominated by machine activity — a noisy proxy for human adoption, not a clean + standalone signal. + - **Branch `rebased` / `migrated` counts** — cheap, but rebase/migration are + maintenance/automation-driven and lower-signal than create/merge/delete; deferred + to keep the permanent field set focused. +- Dashboard rendering and any consumer-side visualization (INFP-550 / SA-184). +- Redefining or altering the existing `database.node_count.total` raw-vertex + metric. +- Persisting logins or any last-login state in the graph database. + +## Dependencies + +- **Receiving-end confirmation (GR-001)**: A confirmation gate with the + cloud-processor and data-mart owners that the additive payload change is + tolerated before shipping. Not a code dependency (the change is additive), but + a release gate. diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md new file mode 100644 index 00000000000..6ca3ccaa2b6 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -0,0 +1,277 @@ +--- +description: "Task list for Phase 1 Telemetry Collection" +--- + +# Tasks: Phase 1 Telemetry Collection + +**Input**: Design documents from `specs/telemetry-collection-infp-589/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/telemetry-payload.md + +**Tests**: Included — Constitution IV requires component tests for SC-001/SC-002/SC-003 and a +unit test for the degradation helper. TDD: write each test first and confirm it fails before +implementing. + +**Organization**: Tasks are grouped by user story. The degradation helper, the additive model +changes, and the `payload_format` bump are genuinely shared, so they live in Foundational +(Phase 2). US4 (resilient payload, P1) is realized by that shared mechanism plus a +full-payload resilience test that runs last, since it asserts every in-scope field is present. + +## Conventions & Guardrails + +- **No work-item / requirement IDs in source.** Do NOT write `FR-xxx`, `SC-xxx`, `IFC-xxxx`, + `INFP-589`, or task IDs in code, docstrings, comments, or test names + (`.agents/rules/code-doc-style.md`). Those IDs stay in this file and in commit messages. +- **No mocking.** No `unittest.mock` / `MagicMock` / `patch`. Use plain coroutines as test + doubles for the degradation helper; use real fixtures/`prefect_test_fixture` for component + tests. `freezegun` is the allowed tool for pinning time; `get_run_logger` may be handled per + the allowed `testing-python.md` pattern when calling `@task`/`@flow` via `.fn`. +- **Branch-safe counts.** Node/account counts go through `NodeManager.count` on the default + branch — never raw `count_nodes(label=...)` for `corenode`. +- **Additive only.** Never change an existing field's name/type/meaning (the sole exception is + widening the `node_count` value type so the new `corenode` key may be `null`). +- **Keyword arguments** for all calls; full type hints; `str | None` style. +- Commit after each task or logical group. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Pre-flight; the telemetry module already exists, so setup is minimal. + +- [X] T001 [P] Add a Towncrier changelog fragment under `changelog/` (e.g. `+telemetry-phase1.added.md`) describing the new additive telemetry fields and the `payload_format` bump. +- [X] T002 [P] Confirm the telemetry test layout exists and create empty skeletons where missing: `backend/tests/unit/telemetry/test_degradation.py`, `backend/tests/component/telemetry/test_tasks.py` (mirror source structure; no assertions yet). + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared payload contract + degradation mechanism that every metric depends on. + +**⚠️ CRITICAL**: No user story metric can be wired until this phase is complete. + +- [X] T003 Bump `TELEMETRY_VERSION` in `backend/infrahub/telemetry/constants.py` from `"20250318"` to `"20260628"` (this also advances `DEFAULT_PAYLOAD_FORMAT`). +- [X] T004 In `backend/infrahub/telemetry/models.py`, add the additive payload models: + - `TelemetryAccountData` with `active: int | None` and `groups: int | None`. + - `TelemetryActivity24hData` with `logins`, `unique_logins`, `checks_started`, `checks_passed`, `checks_failed`, `artifacts_created`, `artifacts_updated`, `branches_created`, `branches_merged`, `branches_deleted`, `webhooks_fired_success`, `webhooks_fired_failure`, all `int | None`. + - Extend `TelemetryBranchData` with `active: int | None = None` (keep `total: int`). + - Widen `TelemetryDatabaseData.node_count` value type to `dict[str, int | None]`. + - Add `accounts: TelemetryAccountData` and `activity_24h: TelemetryActivity24hData` to `TelemetryData` (always-present objects; per-field nullability). +- [X] T005 [P] Write the degradation-helper unit test in `backend/tests/unit/telemetry/test_degradation.py` (TDD — must fail first): a coroutine that raises → helper returns `None`; a coroutine returning `0` → `0`; a coroutine returning `N` → `N`. No DB, no mock (plain coroutines as doubles). +- [X] T006 Implement the async graceful-degradation helper in `backend/infrahub/telemetry/tasks.py`: runs a metric coroutine, returns its result, and on any exception logs a warning and returns `None`. Make T005 pass. + +**Checkpoint**: Payload models, version bump, and degradation helper ready. Stories can begin. + +--- + +## Phase 3: User Story 1 — Activity 24h enabler (Priority: P1) 🎯 MVP + +**Goal**: Emit `activity_24h` (logins, unique_logins, webhooks success/failure) over the +trailing 24h via a NEW windowed Prefect path, without touching the existing unwindowed event +tally; wire it into the daily payload with per-field degradation. + +**Independent Test**: Seed login + webhook-process records inside and outside the trailing 24h; +the gathered `activity_24h` reflects exactly the in-window records, `unique_logins` collapses +repeat logins per account, and the existing `prefect.events.*` output is unchanged. + +### Tests for User Story 1 (write first, must fail) ⚠️ + +- [X] T007 [P] [US1] Component test for windowed logins + unique_logins in `backend/tests/component/telemetry/test_task_manager.py`: with `freezegun` pinning "now" to an off-midnight time (e.g. 02:37 UTC), seed `account.logged_in` events placed relative to the previous-UTC-day boundary — inside the window, just before `window_start`, and just after `window_end` (and repeat logins from one account); assert in-window-only `logins`, distinct-account `unique_logins`, and that the boundary records are excluded (proves the window is anchored to midnight, not to `now`). +- [X] T008 [P] [US1] Component test for webhook success/failure split over 24h in `backend/tests/component/telemetry/test_task_manager.py`: seed terminal `webhook-process` flow runs (completed + failed) in- and out-of-window; assert correct counts and that non-terminal runs are excluded. +- [X] T009 [P] [US1] Regression test asserting `gather_prefect_events` output is unchanged (existing unwindowed tally still present and untouched). + +### Implementation for User Story 1 + +- [X] T009b [US1] In `backend/infrahub/telemetry/task_manager.py` (or a small `telemetry/window.py` helper), add a deterministic window function returning `[window_start, window_end)` where `window_end = floor_to_midnight_utc(now)` and `window_start = window_end - 24h` (previous full UTC calendar day). All activity_24h queries use this — never raw `now`. +- [X] T010 [US1] In `backend/infrahub/telemetry/task_manager.py`, add a NEW windowed event counter that posts to `/events/count-by/event` with an `occurred` window (`since = window_start`, `until = window_end` from T009b) plus the `event.name` filter — separate from `gather_prefect_events`, which stays untouched. +- [X] T011 [US1] In `task_manager.py`, add a windowed unique-account counter posting to `/events/count-by/resource` over the same `account.logged_in` window; the number of resource buckets (keyed by `infrahub.account.{account_id}`) is `unique_logins`. +- [X] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the same `[window_start, window_end)` window (T009b), splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`); non-terminal runs counted in neither. +- [X] T013 [US1] In `task_manager.py`, add `gather_activity_24h(client) -> TelemetryActivity24hData` assembling the login + webhook counts (US1 fields), each obtained through the degradation helper so one failing source nulls only its own field. (US5 extends this same function with the check/artifact counts.) +- [X] T014 [US1] In `backend/infrahub/telemetry/tasks.py`, wire `activity_24h` into `gather_anonymous_telemetry_data` (gather via the Prefect client path; the object is always present). + +**Checkpoint**: `activity_24h` present and windowed; existing event output intact. MVP testable. + +--- + +## Phase 4: User Story 2 — Accounts & branches adoption (Priority: P2) + +**Goal**: Emit `accounts.active`, `accounts.groups`, and `branches.active`. + +**Independent Test**: Seed known active/inactive accounts, account groups, and +open/system branches; assert each reported count matches the fixture exactly. + +### Tests for User Story 2 (write first, must fail) ⚠️ + +- [X] T015 [P] [US2] Component test for `accounts.active` / `accounts.groups` in `backend/tests/component/telemetry/test_tasks.py`: seed a known mix of active/inactive `CoreAccount` and a known number of `CoreAccountGroup`; assert exact counts via the gather. +- [X] T016 [P] [US2] Test for `branches.active` (registry-based) in `backend/tests/component/telemetry/test_tasks.py`: with open + system branches present, assert the count excludes the default (`main`) and global (`-global-`) branches. + +### Implementation for User Story 2 + +- [X] T017 [US2] Add `gather_account_information(db) -> TelemetryAccountData` (in `backend/infrahub/telemetry/tasks.py`, or a small `backend/infrahub/telemetry/accounts.py` if cohesion warrants): `active` via `NodeManager.count(CoreAccount, filters={"status__value": "active"})`, `groups` via `NodeManager.count(CoreAccountGroup)`, both on the default branch, each through the degradation helper. +- [X] T018 [US2] In `gather_anonymous_telemetry_data` (`tasks.py`), wire `accounts` (from T017) and compute `branches.active` from `registry.branch.values()` excluding `is_default` and `is_global`, via the degradation helper; keep `branches.total` unchanged. + +**Checkpoint**: Account + branch adoption metrics present and exact; `branches.total` untouched. + +--- + +## Phase 5: User Story 3 — Branch-correct node counts: `corenode` + `user` (Priority: P2) + +**Goal**: Emit `database.node_count.corenode` (all managed nodes) and `database.node_count.user` +(user/business nodes in user-defined namespaces) via the branch/temporal-correct count path, +leaving `node_count.total` (raw vertices) unchanged. + +**Independent Test**: Seed a known number of managed nodes, independently compute the expected +count, and assert `node_count["corenode"]` matches exactly (±0); seed user-defined + `Core` nodes +and assert `node_count["user"]` counts only the user-defined ones with `user ⊆ corenode ⊆ total`. + +### Tests for User Story 3 (write first, must fail) ⚠️ + +- [x] T019 [P] [US3] Component test in `backend/tests/component/telemetry/test_datatabase.py`: seed N managed nodes via existing schema fixtures (`backend/tests/helpers/schema/`), independently compute N, assert `node_count["corenode"] == N` exactly and that `node_count["total"]` (raw) is unchanged and `>= N`. + +### Implementation for User Story 3 + +- [x] T020 [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["corenode"]` via `NodeManager.count(db, schema=InfrahubKind.NODE, branch=)`, wrapped so a failure sets `corenode=None` without affecting `node_count["total"]` or the existing graph-label keys (do NOT use raw `count_nodes(label=...)`). +- [x] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics at the namespace level: `total` (raw vertices), `corenode` (all managed nodes — `Core` + `Builtin` + user-defined namespaces), and `user` (customer-facing subset excluding the `Core` management namespace), noting they nest `user ⊆ corenode ⊆ total`. No tickets/IDs in source. +- [x] T021b [US3] Component test in `backend/tests/component/telemetry/test_datatabase.py`: seed user-defined nodes (`Test` namespace via `car_person_schema`) + at least one `Core` node (a `CoreAccount`); assert `node_count["user"]` equals the user-defined count exactly (Core node excluded) and `user <= corenode <= total`, with `user < corenode` when a Core node exists. +- [x] T021c [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["user"]` = sum of `NodeManager.count` over concrete node kinds in user-editable namespaces (`SchemaNamespace.user_editable`, i.e. `namespace not in RESTRICTED_NAMESPACES`), excluding group-generic kinds; wrapped so a failure sets only `user=None`. Update `test_tasks.py` full-payload presence test to assert `node_count["user"]` is present. + +**Checkpoint**: `corenode` + `user` exact and branch-correct; `user` excludes `Core`/`Builtin`; raw `total` preserved. + +--- + +## Phase 6: User Story 5 — Depth-of-adoption: checks, artifacts & branch lifecycle (Priority: P2) + +**Goal**: Emit `activity_24h.checks_started/passed/failed`, +`activity_24h.artifacts_created/updated`, and +`activity_24h.branches_created/merged/deleted` from events that already flow today, reusing the +US1 windowed event path unchanged. + +> Rides entirely on US1's windowed counter (T009b/T010). Each metric is one more event name in +> the same query — verified present via `get_all_events()`: `validator.started/passed/failed`, +> `artifact.created/updated`, `branch.created/merged/deleted`. Branch *lifetime* (duration) is +> NOT included — it needs per-branch correlation (Phase 2). + +**Independent Test**: Seed `validator.*`, `artifact.*`, and `branch.*` events in- and +out-of-window; assert each count reflects exactly the in-window events; assert genuine-empty → `0`. + +### Tests for User Story 5 (write first, must fail) ⚠️ + +- [X] T022 [P] [US5] Component test in `backend/tests/component/telemetry/test_task_manager.py` (parametrized off the US1 windowing fixture): seed `validator.started/passed/failed`, `artifact.created/updated`, and `branch.created/merged/deleted` events in- and out-of-window; assert `checks_*`, `artifacts_*`, and `branches_*` equal the in-window counts and that out-of-window events are excluded. + +### Implementation for User Story 5 + +- [X] T023 [US5] Extend the windowed event counter (T010) to also count `validator.started`, `validator.passed`, `validator.failed`, `artifact.created`, `artifact.updated`, `branch.created`, `branch.merged`, `branch.deleted`, and extend `gather_activity_24h` (T013) to populate the eight new fields, each through the degradation helper (per-field null isolation). No change to `gather_prefect_events`. + +**Checkpoint**: Depth-of-adoption check/artifact/branch metrics present and windowed. + +--- + +## Phase 7: User Story 4 — Resilient payload (Priority: P1) + +**Goal**: Guarantee the cross-cutting resilience contract end-to-end: every in-scope field is +present; a failing source yields `null` (not a dropped payload); a genuine empty yields `0`. + +> The mechanism (degradation helper) is delivered in Foundational and consumed by each story +> above. This phase validates the whole payload, so it runs after US1–US3 and US5 are wired. + +**Independent Test**: Run the gather flow; assert all in-scope fields present. Force one source +to fail (inject a failing collaborator/fixture — no mock); assert that field is `null`, all +others populated, and the payload is still built and stored. Assert genuine-empty → `0`. + +### Tests for User Story 4 (write first, must fail) ⚠️ + +- [X] T024 [US4] Component test in `backend/tests/component/telemetry/test_tasks.py`: run `gather_anonymous_telemetry_data` on a healthy stack and assert presence of `accounts.{active,groups}`, `branches.active`, `database.node_count.corenode`, and all `activity_24h` fields (`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, `branches_created/merged/deleted`, `webhooks_fired_success/failure`). +- [X] T025 [US4] Resilience test in `backend/tests/component/telemetry/test_tasks.py`: make one source fail via an injected failing collaborator/fixture (no mock); assert that field is `null`, every other field is populated, and the snapshot is still stored. Add a genuine-empty case asserting `0`, not `null`. + +### Implementation for User Story 4 + +- [X] T026 [US4] Audit `gather_anonymous_telemetry_data` in `backend/infrahub/telemetry/tasks.py` to ensure every new metric (accounts, branches.active, corenode, each activity_24h field incl. checks/artifacts) is gathered through the degradation helper — no new metric can raise out of the orchestrator. If the current wiring doesn't expose a clean no-mock failure seam for T025, introduce one (e.g. an injectable gather collaborator) following backend component-design DI rules. + +**Checkpoint**: Whole payload resilient; one failing source never drops the rest. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +- [X] T027 [P] Run the telemetry suites: `uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q` (set `DOCKER_HOST` for component tests). — 62 passed. +- [X] T028 [P] `uv run invoke format lint` and resolve any findings in the telemetry module. — ruff format/check + mypy clean (scoped to telemetry). +- [X] T029 Run the `quickstart.md` validation steps end-to-end and confirm `payload_format == "20260628"` in a stored snapshot. — verified via a full local collection simulation (real gather + all new fields populated; `TELEMETRY_VERSION == "20260628"`). +- [ ] T030 **Governance gate (GR-001)** — before merge/release, confirm with the cloud-processor owner and the data-mart owner that the receiver tolerates the `payload_format` bump, ignores unknown fields, and tolerates `null` values (including `corenode` inside `node_count`). Record the confirmation on the PR / tracking ticket. (Process task, not code — remains OPEN, external dependency.) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: no dependencies. +- **Foundational (Phase 2)**: depends on Setup; **blocks all stories** (models, helper, version). +- **US1 (Phase 3)**, **US2 (Phase 4)**, **US3 (Phase 5)**: each depends only on Foundational; mutually independent (different gather functions / files), so parallelizable across developers. +- **US5 (Phase 6)**: depends on US1 (reuses its windowed counter); otherwise independent. +- **US4 (Phase 7)**: depends on US1–US3 and US5 being wired (it asserts the full payload). +- **Polish (Phase 8)**: depends on all desired stories complete. + +### Within Each User Story + +- Tests are written first and must fail before implementation. +- US1: window helper (T009b) → windowed counters (T010–T012) → assembler (T013) → orchestrator wiring (T014). +- US2: gather function (T017) → orchestrator wiring (T018). +- US3: db count (T020) → docs note (T021). +- US5: extend the windowed counter + assembler (T023) after US1's T010/T013 exist. + +### Parallel Opportunities + +- T001 / T002 (Setup) in parallel. +- T005 (helper test) parallel with T003/T004 (constant + models). +- US1 test tasks T007/T008/T009 in parallel; US2 T015/T016 in parallel. +- With capacity: US1, US2, US3 proceed in parallel once Foundational is done; US5 follows US1. +- Polish T027/T028 in parallel. + +--- + +## Parallel Example: User Story 1 + +```bash +# Write US1 tests together (they must fail first): +Task: "Component test windowed logins/unique_logins in backend/tests/component/telemetry/test_task_manager.py" +Task: "Component test webhook success/failure split in backend/tests/component/telemetry/test_task_manager.py" +Task: "Regression test gather_prefect_events unchanged" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1) + +1. Phase 1 Setup → Phase 2 Foundational (CRITICAL). +2. Phase 3 US1 (activity_24h enabler). +3. **STOP and VALIDATE**: windowing + existing-output-untouched, independently. +4. Demo the new `activity_24h` object. + +### Incremental Delivery + +1. Setup + Foundational → contract + mechanism ready. +2. US1 → calendar-day-windowed activity (MVP). +3. US2 → account/branch adoption. +4. US3 → branch-correct scaling count. +5. US5 → depth-of-adoption checks/artifacts (rides on US1). +6. US4 → full-payload resilience guarantee. +7. Polish → suite green, lint clean, GR-001 governance confirmed. + +--- + +## Notes + +- `[P]` = different files, no incomplete-task dependency. +- `[Story]` label maps each task to a user story for traceability (this file only — never in source). +- Verify each test fails before implementing. +- US4's value is P1, but its full-payload assertion depends on US1–US3 + US5, so it is scheduled last. +- US5 (checks/artifacts/branch-lifecycle counts) is a user-directed pull-in from Phase 2 — + cheap because the events already flow; see `alignment-check.md` §6 for the + sanctioned-scope-expansion record. +- Out of scope (do not implement): branch *lifetime* + (duration — needs correlation); PR "merged-without-review" (needs correlation); node churn + (`node.*` — machine-dominated, noisy); branch `rebased`/`migrated` counts (low-signal); + remaining Phase 2 metrics (generators/transforms, tokens, CLI/MCP/Sync, licensing); + dashboards; redefining `node_count.total`; persisting logins in Neo4j. diff --git a/dev/specs/telemetry-collection-infp-589/window_probe.py b/dev/specs/telemetry-collection-infp-589/window_probe.py new file mode 100644 index 00000000000..0b8d683d3cf --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/window_probe.py @@ -0,0 +1,83 @@ +# ruff: noqa: INP001 # standalone manual-testing script, not a package module +"""Ad-hoc telemetry window probe (manual testing aid; not part of the app or tests). + +Counts one activity_24h metric across three windows so a just-now action can be confirmed +without waiting for the calendar day to roll: + + YESTERDAY -> the window today's telemetry snapshot reports + TODAY -> the window tomorrow's snapshot will report (where an action done now lands) + LAST 3H -> a tight window around right now, to isolate the action you just took + +Run inside a worker container (has the Prefect client + PREFECT_API_URL): + + docker exec infrahub-task-worker-1 python /source/dev/specs/telemetry-collection-infp-589/window_probe.py [metric] + +`metric` defaults to `logins`. Event-based metrics: logins, branches_created/merged/deleted, +checks_started/passed/failed, artifacts_created/updated (or a raw Prefect event name). +The special metric `webhooks` counts webhook-process flow-runs and reports success/failure. +""" + +import asyncio +import sys +from datetime import UTC, datetime, timedelta + +from prefect.client.orchestration import PrefectClient, get_client + +from infrahub.events.account_action import AccountLoggedInEvent +from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent +from infrahub.events.branch_action import BranchCreatedEvent, BranchDeletedEvent, BranchMergedEvent +from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent +from infrahub.telemetry.task_manager import count_webhook_runs, count_windowed_event, count_windowed_unique_resources +from infrahub.telemetry.utils import get_activity_window + +EVENTS = { + "logins": AccountLoggedInEvent.event_name, + "branches_created": BranchCreatedEvent.event_name, + "branches_merged": BranchMergedEvent.event_name, + "branches_deleted": BranchDeletedEvent.event_name, + "checks_started": ValidatorStartedEvent.event_name, + "checks_passed": ValidatorPassedEvent.event_name, + "checks_failed": ValidatorFailedEvent.event_name, + "artifacts_created": ArtifactCreatedEvent.event_name, + "artifacts_updated": ArtifactUpdatedEvent.event_name, +} + +key = sys.argv[1] if len(sys.argv) > 1 else "logins" + + +def windows_for(now: datetime) -> dict[str, tuple[datetime, datetime]]: + return { + "YESTERDAY (today's snapshot)": get_activity_window(now), + "TODAY (tomorrow's snapshot) ": get_activity_window(now + timedelta(days=1)), + "LAST 3H (activity just now)": (now - timedelta(hours=3), now), + } + + +async def probe_webhooks(client: PrefectClient, windows: dict[str, tuple[datetime, datetime]]) -> None: + for label, (start, end) in windows.items(): + success, failure = await count_webhook_runs.fn(client=client, window_start=start, window_end=end) + print(f"{label} success={success} failure={failure}") + + +async def probe_event(client: PrefectClient, event: str, windows: dict[str, tuple[datetime, datetime]]) -> None: + for label, (start, end) in windows.items(): + total = await count_windowed_event.fn(client=client, event_name=event, window_start=start, window_end=end) + unique = await count_windowed_unique_resources.fn( + client=client, event_name=event, window_start=start, window_end=end + ) + print(f"{label} count={total} unique={unique}") + + +async def main() -> None: + now = datetime.now(tz=UTC) + windows = windows_for(now) + print(f"metric: {key}") + print(f"now : {now.isoformat()}\n") + async with get_client(sync_client=False) as client: + if key == "webhooks": + await probe_webhooks(client, windows) + else: + await probe_event(client, EVENTS.get(key, key), windows) + + +asyncio.run(main()) diff --git a/docs/docs/branches/merge.mdx b/docs/docs/branches/merge.mdx index cca1a54aa4b..f9c52de3479 100644 --- a/docs/docs/branches/merge.mdx +++ b/docs/docs/branches/merge.mdx @@ -62,10 +62,71 @@ Once merged, a branch enters a frozen state and no further mutations are allowed The merged source branch can be deleted manually or automatically — see [Delete a branch](./delete.mdx) for the deletion options, including the `delete_branch_after_merge` configuration that removes branches automatically right after a successful merge. +## When a merge fails + +A merge is not a single write and it can be too large to be wrapped in a transaction. While the write protection described above is in place, the merge copies the data into the default branch and, when the source branch changed the schema, applies the schema migrations that follow from it. Only once all of that has succeeded does the merge reach its point of no return, where the source branch becomes frozen and the write protection lifts. All of that work runs on one worker, and that worker can disappear part-way through — the process crashes or is killed, its container is restarted, or the database becomes unreachable while the merge is mid-flight. + +Infrahub is designed so that this leaves a *recoverable* state rather than a silently half-merged default branch. The write protection is not lifted on failure: it stays in place, so nothing writes on top of a partial merge, and the branch keeps its merge status until an administrator recovers it. The trade-off is deliberate — the default branch rejects writes for longer in exchange for never presenting a partially merged graph as if the merge had succeeded. + +### How a failed merge is detected + +A merge cannot report its own death, so Infrahub infers it. A background check runs once a minute and looks for a branch still in the merging state whose merge worker is no longer among the live workers. When it finds one, and the merge has been running longer than the grace period, it records the branch as having failed and escalates the write protection from the transient in-progress block to a recovery-required block. + +:::note +A failed merge is therefore not flagged the instant the worker dies. Expect a few minutes to pass before the rejection changes from `MERGE_IN_PROGRESS` to `MERGE_RECOVERY_REQUIRED`. +::: + +A merge whose worker is still alive is never flagged, however long it runs. + +### The error clients see + +Once the merge is flagged as failed, writes to the default branch and to the merge source branch are rejected with the structured code `MERGE_RECOVERY_REQUIRED` (HTTP status 423): + +```json +{ + "extensions": { + "code": "MERGE_RECOVERY_REQUIRED", + "http_status": 423, + "data": { + "branch_name": "main", + "merging_branch": "my-feature-branch" + } + } +} +``` + +The accompanying message names the remedy directly: + +> A previous merge failed and left the default branch protected. Writes stay blocked until an administrator runs `infrahub recover merge`. Please contact an administrator. + +This is the important distinction from `MERGE_IN_PROGRESS`, which carries the same HTTP status: `MERGE_IN_PROGRESS` is transient and clears on its own, so retrying is the correct response. `MERGE_RECOVERY_REQUIRED` is durable. It does not clear with time and no amount of retrying will lift it — it requires an administrator to act. Automation should treat the two codes differently rather than retrying both. + +:::warning +Client-side retry logic that matches only on HTTP 423 will retry a failed merge forever. Branch on the `code` in `extensions`, not on the status alone. +::: + +### Recovering the failed merge + +An administrator recovers the branch with the [`infrahub recover merge`](../reference/infrahub-cli/infrahub-recover.mdx) CLI command, run against the Infrahub server: + +```shell +infrahub recover merge +``` + +The command finds the failed merge on its own; naming a branch explicitly restricts it to that branch. It first previews what it found — the branch, when the merge started, and any associated Proposed Change — and asks for confirmation before changing anything. `--yes` skips the prompt for unattended use. + +Recovery reverses the partial merge rather than completing it. It rolls back every default-branch write the merge made, returns the branch and any associated Proposed Change to the open state, and lifts the write protection last, so an interruption part-way through leaves the branch protected rather than exposed. It is idempotent: running it again after a partial recovery re-detects the branch and finishes the job, and running it when there is nothing to recover reports that and makes no changes. + +Once recovery reports success, the default branch is writable again and the branch is back in the state it was in before the merge started. The merge can then be retried. + +By default, recovery acts only on a merge whose worker is confirmed dead. A merge that is stuck without an identifiable worker is ambiguous — it can look the same as a healthy merge whose bookkeeping was lost — so recovering it requires `--force`. + ## Related - [Branches](./overview.mdx) — branch lifecycle and concepts - [Proposed Changes](../proposed-changes/overview.mdx) — the recommended path for merging - [Resolve conflicts](./resolve-conflicts.mdx) — handle conflicts before or during merge +- [`infrahub recover`](../reference/infrahub-cli/infrahub-recover.mdx) — CLI reference for recovering a failed merge +- [Error catalogue](../reference/error-catalogue.mdx) — the full `MERGE_IN_PROGRESS` and `MERGE_RECOVERY_REQUIRED` contracts - [Rebase a branch](./rebase.mdx) — keep your branch up-to-date before merging - [Delete a branch](./delete.mdx) — what happens after merging diff --git a/docs/docs/deploy-manage/install-configure/hardware-requirements.mdx b/docs/docs/deploy-manage/install-configure/hardware-requirements.mdx index 40370f0f230..e4b4cd0dabe 100644 --- a/docs/docs/deploy-manage/install-configure/hardware-requirements.mdx +++ b/docs/docs/deploy-manage/install-configure/hardware-requirements.mdx @@ -27,6 +27,25 @@ For cloud deployments, use at least the following machine types: | Oracle Cloud Infrastructure | VM.Standard.E6 | | Alibaba Cloud | ecs.g9a.xlarge | +## Local development and evaluation + +To evaluate Infrahub or build a proof-of-value on a single machine, use the community docker-compose deployment. The requirements below apply to that setup, not to production. + +:::warning + +These are starting points, not a measured specification. Real resource use depends on your dataset size and pipeline concurrency. Validate against your own data with a load test — schema load, data import, and a proposed-change merge — before you treat any figure as a hard requirement. + +::: + +| Resource | Minimum (light evaluation) | Recommended | Notes | +|---------------|----------------------------|-------------|----------------------------------------------------------------------------------------------------------| +| RAM | 8 GB | 16 GB | Recommended matches the general requirements above. | +| CPU | 6 cores | 8 cores | A proposed-change or merge pipeline uses several cores concurrently, so provision headroom. | +| Disk | 20 GB | 40 GB | SSD. Container images require 1.3 GB; data volumes grow with your data and graph history. | +| Storage class | SSD | SSD | Neo4j and PostgreSQL are latency-sensitive, so the storage class matters more than a specific IOPS target. | + +As your dataset grows, large merges and imports slow down on Community Edition — the signal that a single machine is no longer enough. For production scale and performance, see [Community vs enterprise](../../overview/community-vs-enterprise.mdx). + ## Task manager database (PostgreSQL) storage The storage figures in the tables above size the Neo4j graph database. The task manager (Prefect) keeps its own [PostgreSQL database](../../overview/architecture.mdx#task-manager), which you size separately. diff --git a/docs/docs/deploy-manage/install-configure/install/community.mdx b/docs/docs/deploy-manage/install-configure/install/community.mdx index 8c01aeacf3d..06005fcbb50 100644 --- a/docs/docs/deploy-manage/install-configure/install/community.mdx +++ b/docs/docs/deploy-manage/install-configure/install/community.mdx @@ -10,6 +10,8 @@ import ReferenceLink from "../../../../src/components/Card"; Infrahub Community is deployed as a container-based architecture and can be installed using several methods. +Before you install, review the [hardware requirements](../hardware-requirements.mdx), including the local development and evaluation sizing. + diff --git a/docs/docs/faq/faq.mdx b/docs/docs/faq/faq.mdx index 40ff3ff7509..826c8257a3c 100644 --- a/docs/docs/faq/faq.mdx +++ b/docs/docs/faq/faq.mdx @@ -216,9 +216,11 @@ export INFRAHUB_TELEMETRY_OPTOUT=true The following information is included in telemetry: -- Infrahub version -- Platform information -- Anonymous counters about the graph and the schema +- Infrahub version, deployment type, and platform (Python / OS / architecture) +- Anonymous counters about the graph, schema, and features in use +- Adoption counts: active accounts, account groups, and open branches +- Database, worker, and infrastructure statistics +- Daily activity over the previous day: logins, validation checks, artifact generation, branch operations, and webhook deliveries This information is used as aggregated analysis to better understand what and how to improve the project. All information collected is anonymous and the implementation is open source on GitHub. diff --git a/docs/docs/overview/community-vs-enterprise.mdx b/docs/docs/overview/community-vs-enterprise.mdx index 762cf7c1a16..cd347b88a73 100644 --- a/docs/docs/overview/community-vs-enterprise.mdx +++ b/docs/docs/overview/community-vs-enterprise.mdx @@ -153,6 +153,7 @@ This area represents one of the most significant differences between the edition #### Community edition characteristics - **Scale capacity**: Suitable for infrastructures up to ~1,000 devices +- **Database concurrency**: Neo4j Community runs on a single CPU core, so large merges and imports are bound by single-core performance regardless of how many cores the host has - **Database optimization**: Standard optimization suitable for moderate workloads - **Caching**: Basic caching and query optimization - **Deployment model**: Single-instance deployment patterns @@ -165,6 +166,7 @@ Community Edition is fully capable of handling production workloads for small to #### Enterprise edition enhancements - **Scale capacity**: Optimized for environments with 10,000+ devices +- **Database concurrency**: Multi-core Neo4j for parallel query and merge execution - **Database performance**: Advanced tuning, indexing strategies, and query optimization - **Caching architecture**: Enhanced multi-layer caching for improved performance - **High availability**: Built-in clustering support for resilience diff --git a/docs/docs/reference/error-catalogue.mdx b/docs/docs/reference/error-catalogue.mdx index b6e4c435c2a..e3e24560d24 100644 --- a/docs/docs/reference/error-catalogue.mdx +++ b/docs/docs/reference/error-catalogue.mdx @@ -286,7 +286,7 @@ The write was rejected because a branch merge is in progress. The block is trans ### MERGE_RECOVERY_REQUIRED -The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover`. Unlike MERGE_IN_PROGRESS this is not retryable. +The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover merge`. Unlike MERGE_IN_PROGRESS this is not retryable. - **Stability**: `evolving` - **HTTP status**: `423` @@ -305,7 +305,7 @@ The write was rejected because a previous branch merge failed and left the defau "data": null, "errors": [ { - "message": "The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover`. Unlike MERGE_IN_PROGRESS this is not retryable.", + "message": "The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover merge`. Unlike MERGE_IN_PROGRESS this is not retryable.", "extensions": { "code": "MERGE_RECOVERY_REQUIRED", "http_status": 423, diff --git a/docs/docs/reference/infrahub-cli/infrahub-recover.mdx b/docs/docs/reference/infrahub-cli/infrahub-recover.mdx new file mode 100644 index 00000000000..7924286c3e1 --- /dev/null +++ b/docs/docs/reference/infrahub-cli/infrahub-recover.mdx @@ -0,0 +1,46 @@ +# `infrahub recover` + +Recover from failed operations. + +**Usage**: + +```console +$ infrahub recover [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--install-completion`: Install completion for the current shell. +* `--show-completion`: Show completion for the current shell, to copy it or customize the installation. +* `--help`: Show this message and exit. + +**Commands**: + +* `merge`: Recover a failed branch merge. + +## `infrahub recover merge` + +Recover a failed branch merge. + +Roll back the partial graph merge and reset the branch (and any associated proposed change) to +OPEN, then lift the write protection so the default branch is writable again. Idempotent: a run +with nothing to recover reports so and makes no changes. By default only a merge whose worker is +confirmed dead is recovered; pass --force to also recover a merge stuck with an absent/ambiguous +lock. + +**Usage**: + +```console +$ infrahub recover merge [OPTIONS] [BRANCH] [CONFIG_FILE] +``` + +**Arguments**: + +* `[BRANCH]`: Name of the branch to recover; if omitted, the failed merge is auto-detected. +* `[CONFIG_FILE]`: [env var: INFRAHUB_CONFIG; default: infrahub.toml] + +**Options**: + +* `-y, --yes`: Skip the confirmation prompt. +* `-f, --force`: Recover even when the merge lock is absent/ambiguous, not just when the worker is confirmed dead. +* `--help`: Show this message and exit. diff --git a/docs/docs/reference/schema/attribute.mdx b/docs/docs/reference/schema/attribute.mdx index d4344a57b6f..8fcae33d6c4 100644 --- a/docs/docs/reference/schema/attribute.mdx +++ b/docs/docs/reference/schema/attribute.mdx @@ -184,7 +184,7 @@ extensions: | **Optional** | False | | **Default Value** | | | **Constraints** | | -| **Accepted Values** | `ID` `Dropdown` `Text` `TextArea` `DateTime` `Email` `Password` `HashedPassword` `URL` `File` `MacAddress` `Color` `Number` `NumberPool` `Bandwidth` `IPHost` `IPNetwork` `Boolean` `Checkbox` `List` `JSON` `Any` | +| **Accepted Values** | `ID` `Dropdown` `Text` `TextArea` `DateTime` `Email` `Password` `HashedPassword` `URL` `File` `MacAddress` `Color` `Number` `NumberPool` `Bandwidth` `IPHost` `IPNetwork` `IPAddress` `Boolean` `Checkbox` `List` `JSON` `Any` | ### label diff --git a/docs/docs/release-notes/deprecation-guides/schema-load-write-contract.mdx b/docs/docs/release-notes/deprecation-guides/schema-load-write-contract.mdx index 103e567551d..88605f71874 100644 --- a/docs/docs/release-notes/deprecation-guides/schema-load-write-contract.mdx +++ b/docs/docs/release-notes/deprecation-guides/schema-load-write-contract.mdx @@ -5,9 +5,9 @@ user-facing **write** contract. The contract defines which fields a user may set values those fields accept, and which fields are required. Invalid values are reported as field-level errors naming the field and the value that was rejected. -Fields a user may not set are **tolerated and dropped**, not rejected, so reading a schema -back from Infrahub, editing it, and loading it again keeps working without stripping -anything first. +Fields a user may not set are **reported and dropped**. A read-only field — one Infrahub +returns and computes itself — is reported as a warning, so reading a schema back from +Infrahub, editing it, and loading it again keeps working without stripping anything first. This guide explains what changed, who is affected, and how to check a payload before submitting it. @@ -15,7 +15,8 @@ submitting it. :::warning Two categories of payload that earlier versions accepted are now rejected: a payload whose settable values are invalid (out of the allowed set, out of range, or the wrong type), and -a payload with no `version` key. Extra fields are not one of those categories. +attribute `parameters` that belong to a different attribute `kind`. Read-only fields are not +one of those categories — they are accepted with a warning. ::: ## What changed @@ -32,30 +33,67 @@ Schema fields are now classified by who may see or set them: `POST /api/schema/load` validates each submitted node, generic, and extension against the write contract. -### Extra fields are dropped +### Read-only fields are reported as a warning -Read-only fields, internal fields, and fields the contract does not know about at all — a -typo, or a field removed in a newer version — are dropped before the schema is applied. This -happens at every level of the payload: on nodes, generics, extensions, and on their nested -attributes and relationships. No error is raised, and the dropped values have no effect on -the loaded schema. +A read-only field is dropped before the schema is applied and reported on the response, so the +submitted value never takes effect but you are told it was ignored. This happens at every level +of the payload: on nodes, generics, extensions, and on their nested attributes, relationships, +parameters and choices. That means the read-back, edit, re-load round trip works as it always did. A schema fetched with `GET /api/schema` carries `inherited`, `used_by`, and the derived `kind`; submitting it -back unchanged is accepted, and Infrahub re-derives those fields itself. +back unchanged is accepted, Infrahub re-derives those fields itself, and the response lists +one warning per read-only field naming the kinds that carried it: + +```text +'inherited' is a read-only field, the submitted value is ignored [InfraDevice.name, InfraDevice.interfaces] +``` + +Alongside the read-only fields listed above, this also covers a field that belongs somewhere +else in the contract rather than at the place it was used: the `id` and `state` bookkeeping +that a schema dumped from Infrahub's own models carries on nested blocks, and a field +belonging to another variant of the same block — for example `transform` on a computed +attribute whose `kind` is `Jinja2`. + +`infrahubctl schema load` and `infrahubctl schema check` print these warnings after loading; +`infrahubctl validate schema` prints them without contacting a server. + +### Unrecognized fields are rejected + +A field the contract does not know about at all — a typo, or a field removed in a newer +version — is an error naming the field path and the value received: + +```text +nodes[0].attributes[0].optionl: Unknown field, it is not part of the schema (received: True) +``` + +Earlier versions rejected these too, so this is not a change in whether the payload loads — +only in how the problem is reported. The path is now field-level, and the same message is +available offline before you submit. + +Attribute `parameters` that belong to a different attribute `kind` are a change: they +configure nothing on the kind they were set on, and earlier versions accepted them and then +discarded the values, so the setting silently had no effect. + +```text +nodes[0].attributes[0].parameters.start_range: Unknown field, it is not part of the schema (received: 1) +``` ### Invalid values are rejected -What the contract does reject is a field a user *may* set carrying a value it cannot hold: +What the contract does reject is a field a user *may* set carrying a value it cannot hold. +These two are new: - A **constrained** field set outside its allowed values, for example a relationship `cardinality` or an attribute `kind`. - A value **out of range** or of the wrong type for its field, for example an attribute `name` that is shorter than the minimum length or does not match the allowed pattern. + +These two were already rejected and are listed only so the contract reads completely: + - A **missing required** field, for example a node without a `name`, or an attribute without a `kind`. -- A missing `version` at the root of the payload. `version` is required on the write - contract. +- A missing `version` at the root of the payload. Each rejection names the field path and, where applicable, the value received: @@ -81,12 +119,13 @@ and `used_by`. ## Who is affected You are affected if you submit schemas to `POST /api/schema/load` — directly, through -`infrahubctl schema load`, or through the Python SDK — and your payload sets a value the -write contract does not accept, or omits `version`. Values that a previous version stored -without complaint now produce an error instead. +`infrahubctl schema load`, through a repository import, or through the Python SDK — and your +payload sets a value the write contract does not accept, or sets attribute `parameters` that +belong to a different attribute `kind`. Values that a previous version stored, or accepted and +discarded, without complaint now produce an error instead. -You are not affected by extra fields. A payload that echoes back derived or read-only -fields, or that contains a field Infrahub no longer recognizes, still loads. +You are not affected by read-only fields. A payload that echoes back derived or read-only +fields still loads, with a warning for each. ## Check a payload offline @@ -100,6 +139,8 @@ from infrahub_sdk.schema.validate import validate_schema # schema is your schema-root payload: {"version": "1.0", "nodes": [...], "generics": [...]} result = validate_schema(schema=schema) +for message in result.warning_messages: + print(message) if not result.valid: for message in result.messages: print(message) @@ -115,7 +156,10 @@ nodes[0].relationships[0].cardinality: Input should be 'one' or 'many' (received Every entry in `result.errors` carries the field path in `.field` and the full message in `.message`. `result.raise_for_status()` raises a `ValueError` joining all the messages when -the payload is invalid. +the payload is invalid. Each entry in `result.warnings` additionally carries the schema kind +and element that set the field in `.kind` and `.element`, and in `.name` the field named +relative to them — so a nested block is unambiguous (`parameters.id` rather than `id`, which +*is* settable on the attribute itself). To make a rejected payload submittable: @@ -123,11 +167,18 @@ To make a rejected payload submittable: value. 2. Correct the value so it satisfies the contract — pick an allowed value for a constrained field, bring an out-of-range value into range, or supply a missing required field. -3. Add `version` at the root of the payload if it is absent. -4. Re-run `validate_schema()` until it returns `valid = True`, then load. - -You do not need to remove read-only or unrecognized fields. Leaving them in place is -harmless. +3. Remove or fix any field reported as unknown. A typo is the usual cause; check the spelling + against the [node schema reference](../../reference/schema/node). +4. Add `version` at the root of the payload if it is absent. +5. Re-run `validate_schema()` until it returns `valid = True`, then load. + +You do not need to remove read-only fields. Leaving them in place is harmless, and the +warnings tell you which values had no effect. + +Extra fields are only reported once the payload is otherwise valid, because the contract that +applies at a given place in the payload is resolved from the validated document. A payload +rejected for another reason reports that reason first, and names its extra fields on the next +run. Because the write model is versioned and shipped inside the SDK package, installing the SDK that matches your Infrahub version gives you the exact contract the server enforces. diff --git a/docs/docs/release-notes/infrahub/release-1_8_0.mdx b/docs/docs/release-notes/infrahub/release-1_8_0.mdx index 4a97e1d2599..ada9f4807f5 100644 --- a/docs/docs/release-notes/infrahub/release-1_8_0.mdx +++ b/docs/docs/release-notes/infrahub/release-1_8_0.mdx @@ -21,15 +21,15 @@ description: "File objects, automatic branch freeze-on-merge, resource pools in -We're excited to announce the release of Infrahub, v1.8.0! +Infrahub v1.8.0 is now available. -This release introduces a file objects feature, adds stronger branch life-cycle controls with automatic freeze-on-merge, and adds support for resource pools in object templates. The Infrahub Backup tool now fully supports Kubernetes deployments. +This release introduces a file objects feature, adds stronger branch lifecycle controls with automatic freeze-on-merge, and adds support for resource pools in object templates. The Infrahub Backup tool now fully supports Kubernetes deployments. ## Main changes ### File Object: Upload and attach files to Infrahub objects -Infrahub can now store files -- Text files, PDF, images, spreadsheets, KMZ files, and any other format -- directly as objects in the database. With the new `CoreFileObject` generic, you can define custom file types in your schema. Object files behave as all other objects in Infrahub, you can fully customize the schema and relate (or attach) them to other objects. +Infrahub can now store files -- Text files, PDF, images, spreadsheets, KMZ files, and any other format -- directly as objects in the database. With the new `CoreFileObject` generic, you can define custom file types in your schema. File objects behave like all other objects in Infrahub. You can fully customize the schema and relate (or attach) them to other objects. The contents of the file will be rendered in Infrahub's web interface, if the file has one of the following file types: @@ -43,7 +43,7 @@ The contents of the file will be rendered in Infrahub's web interface, if the fi - text/plain - text/markdown - application/xml -- text/csv; +- text/csv - image/png - image/jpeg - image/gif @@ -77,6 +77,8 @@ The maximum file size defaults to 50 MB and is configurable via the `INFRAHUB_ST ![File Object](../../media/release_notes/infrahub_1_8_0/file_object.png) +**Learn more:** [File objects](../../schema/file-object.mdx) + ### Freeze branch after merge When a branch is merged -- either through a direct branch merge or via a Proposed Change -- it now transitions to a frozen state where no further mutations are allowed. This prevents a class of data integrity issues that could occur when a branch was modified or merged a second time after its initial merge. @@ -86,6 +88,8 @@ Both the backend API and the frontend UI enforce this freeze: - The UI disables editing controls and shows a visual indication that the branch is frozen - Creating a new Proposed Change for an already-merged branch is prevented +**Learn more:** [Merging branches](../../branches/merge.mdx) + ### Resource pool references in object templates Object templates can now reference resource pools (IP Address, IP Prefix, and Number pools). Previously, adding a resource pool to a template would allocate a resource to the template itself -- not the intended behavior. In 1.8, pool references on templates are stored as metadata. When an object is created from the template, the resource is allocated from the specified pool at creation time. @@ -99,6 +103,8 @@ The feature introduces a new `relationship_properties` field in the GraphQL sche A database migration is included to convert any existing template-IP relationships that incorrectly have pool sources into the new `_from_resource_pool` relationship format. +**Learn more:** [Allocate resources from pools in templates](../../object-templates/allocate-resources-from-pools.mdx) + ### Consult the diff of a proposed change after branch deletion The data and schema diff of a Proposed Change is now preserved and accessible even after the associated branch has been deleted. This is essential for audit and compliance workflows -- during incident investigation or regulatory review, users need to inspect what changes were made, by whom, and when, regardless of whether the source branch still exists. @@ -107,9 +113,11 @@ The diff data is now tied to the Proposed Change itself rather than solely to th This feature is a prerequisite for a future capability to automatically delete branches after merge, which will help prevent stale branches that can cause Git synchronization issues. -### Read-only repository: update to latest button +**Learn more:** [Proposed changes](../../proposed-changes/overview.mdx) + +### Read-only repository: Update to Latest button -Managing read-only Git repositories is now simpler. A new "Update to Latest" button fetches and imports the latest commit from the tracked branch directly from the UI. Previously, users had to manually copy and paste commit hashes from an external source to update what Infrahub was tracking. The existing reimport action has been renamed to "Reimport Current Commit" to clearly distinguish between re-processing the current commit and pulling the latest. +A new "Update to Latest" button fetches and imports the latest commit from the tracked branch directly from the UI. Previously, users had to manually copy and paste commit hashes from an external source to update what Infrahub was tracking. The existing reimport action has been renamed to "Reimport Current Commit" to clearly distinguish between re-processing the current commit and pulling the latest. Repositories now also support providing a Git Tag as the reference (ref) to track for a repository. @@ -117,7 +125,7 @@ Repositories now also support providing a Git Tag as the reference (ref) to trac The `infrahub-backup` CLI tool now fully supports Kubernetes deployments for both backup and restore operations. The tool automatically detects whether Infrahub is running on Docker Compose or Kubernetes and adjusts its behavior accordingly. -For Kubernetes deployments we can now install `infrahub-backup` using the Infrahub Helm Chart. This is now the recommended installation method for Kubernetes deployments. +For Kubernetes deployments you can now install `infrahub-backup` using the Infrahub Helm Chart. This is now the recommended installation method for Kubernetes deployments. See the [installation instructions](https://docs.infrahub.app/backup/guides/install#enable-via-infrahub-helm-chart-recommended) for more details Key new capabilities: @@ -128,7 +136,7 @@ Key new capabilities: ### Display artifact count in proposed changes -The Proposed Changes detail view now displays item counts on all tabs, giving you immediate visibility into how many changes exist in each category: +The Proposed Changes detail view now displays item counts on all tabs, so you can see how many changes exist in each category: - **Data** -- count of added, updated, and removed nodes - **Files** -- total count of changed files across repositories @@ -139,7 +147,7 @@ The Proposed Changes detail view now displays item counts on all tabs, giving yo ### Branch list page improvements -The branch list page continues to evolve with richer information and better usability: +The branch list page adds: - **Created By** column shows who created each branch (available for branches created after 1.7) - **Proposed Changes** column links directly to associated proposed changes @@ -153,7 +161,7 @@ The branch list page continues to evolve with richer information and better usab ### General UI improvements -Several smaller improvements enhance the day-to-day experience: +Several smaller improvements: - **Schema field shortcuts** -- clicking an attribute or relationship label in the object detail view opens the schema viewer modal, scrolling directly to the relevant field definition - **Field type icons** -- icons next to field names indicate the attribute type (text, number, boolean) and relationship schema @@ -172,7 +180,7 @@ Notable SDK changes in this release: ## Migration of an Infrahub instance -Before you upgrade an instance of Infrahub, we strongly advise you to delete branches that are no longer needed within Infrahub. Deleting old branches helps speeding up the upgrade process and to avoid spending time running migrations for branches that are no longer needed. +Before you upgrade an instance of Infrahub, we strongly advise you to delete branches that are no longer needed within Infrahub. Deleting old branches helps speed up the upgrade process and avoid spending time running migrations for branches that are no longer needed. **Please** make sure to upgrade any existing installations of the infrahub-sdk to v1.19.0. @@ -194,11 +202,11 @@ Below are some example ways to get the latest version of Infrahub in your enviro - `export VERSION="1.8.0"; docker compose pull && docker compose up -d` - For deployments via Kubernetes, utilize the latest version of the Helm chart supplied with this release -**Second**, once you have gotten the desired version of Infrahub in your environment, we need to run any needed migrations. +**Third**, once you have the desired version of Infrahub in your environment, run any needed migrations. Infrahub provides the `infrahub upgrade` command to start these migrations. -> Note: If you are running Infrahub in Docker/K8s, this command need to run from a container where Infrahub is installed. +> Note: If you are running Infrahub in Docker/K8s, this command needs to run from a container where Infrahub is installed. ```shell docker compose exec infrahub-server infrahub upgrade diff --git a/docs/docs/release-notes/infrahub/release-1_9_0.mdx b/docs/docs/release-notes/infrahub/release-1_9_0.mdx index e5a2e29e078..06d050c1732 100644 --- a/docs/docs/release-notes/infrahub/release-1_9_0.mdx +++ b/docs/docs/release-notes/infrahub/release-1_9_0.mdx @@ -22,9 +22,9 @@ description: "Two headline additions: an interactive schema visualizer that turn -We're excited to announce the release of Infrahub, v1.9.0! +Infrahub v1.9.0 is now available. -Two headline additions lead the release: a brand-new **interactive schema visualizer** that turns your schema into a navigable graph, and **syslog log forwarding** for Infrahub Enterprise - giving operators a live view into both the schema and the runtime. Around those, the release is centred on three themes: **performance** - Jinja2 computed attributes now recalculate inline on local changes instead of spawning a background task per node; **artifact composition** - reusable GraphQL fragment files in `.infrahub.yml` and artifact content composition via Jinja2 filters; and **lifecycle & auditability** - automatic branch deletion after merge, login/logout activity events, and custom HTTP headers on webhooks. +Two headline additions lead the release: a brand-new **interactive schema visualizer** that turns your schema into a navigable graph, and **syslog log forwarding** for Infrahub Enterprise - so operators can see both the schema and the runtime. Alongside these, the release is centred on three themes: **performance** - Jinja2 computed attributes now recalculate inline on local changes instead of spawning a background task per node; **artifact composition** - reusable GraphQL fragment files in `.infrahub.yml` and artifact content composition via Jinja2 filters; and **lifecycle & auditability** - automatic branch deletion after merge, login/logout activity events, and custom HTTP headers on webhooks. ⚠️ After upgrading to this release, every Infrahub user account that originated from an OAuth2 or OIDC identity provider must log in again. The login flow captures additional information that Infrahub now stores against these accounts. @@ -36,25 +36,27 @@ A future release will require this information to already be present. Any accoun A new **schema graph visualizer** turns your schema into an interactive, navigable graph. Instead of opening YAML files or paging through the schema viewer one schema node at a time, you can see the whole model - nodes, generics, profiles, and templates - laid out together with their relationships drawn as edges between them. -The visualizer is a canvas you can drag, zoom, and pan, with an automatic layout. Each schema kind is colour-coded so you can tell nodes, generics, profiles, and templates apart at a glance. Relationship edges with `many` cardinality are animated, and self-referencing relationships are highlighted. +The visualizer is a canvas you can drag, zoom, and pan, with an automatic layout. Each schema kind is colour-coded so you can tell nodes, generics, profiles, and templates apart immediately. Relationship edges with `many` cardinality are animated, and self-referencing relationships are highlighted. You'll find it as a new **Graph** toggle on the Schema page, switching between the existing schema viewer and the graph view. Clicking the `view in graph` button in a schema node's details opens the graph with that node pre-highlighted. The visualizer includes: -- A **filter panel** to toggle whole namespaces or individual schema types in and out of the view, so you can isolate just the part of the model you care about. +- A **filter panel** to toggle whole namespaces or individual schema types in and out of the view, so you can isolate the part of the model you care about. - A **node details panel** that opens on click to show the selected schema's attributes and relationships, so you don't need to switch tabs to read the definition. - Context menus on nodes and edges for quick navigation. - **Zoom, pan, and fit-to-view** controls plus **PNG export** of the current view - useful for docs and architecture reviews. -- State persistence, so the filters and layout you set up stick around between sessions. +- State persistence, so the filters and layout you set up persist between sessions. ![Schema visualizer](../../media/release_notes/infrahub_1_9_0/schema_visualizer.png) ### Syslog log forwarding *(Enterprise)* -Infrahub Enterprise now supports native Syslog forwarding to external SIEM systems such as Splunk, Datadog, and ELK. All `infrahub.*` activity events - including the new login/logout events - are can be forwarded continuously. Permission-denied errors on rejected GraphQL and REST requests are forwarded as `infrahub.permission.denied` events, and each destination can optionally include Infrahub's own application logs, filtered by a minimum severity. The MSG field of each syslog entry carries the JSON representation of the event. +Infrahub Enterprise now supports native syslog forwarding to external SIEM (Security Information and Event Management) systems such as Splunk, Datadog, and ELK. All `infrahub.*` activity events can be forwarded continuously, including the new login/logout events. Permission-denied errors on rejected GraphQL and REST requests are forwarded as `infrahub.permission.denied` events. Each destination can also include Infrahub's own application logs, filtered by a minimum severity. The MSG field of each syslog entry contains the event as JSON. -Configuration is via the Infrahub configuration file or environment variables and supports multiple destinations, TCP or UDP transport, optional TLS, and both RFC 5424 and RFC 3164 formats - designed to meet enterprise security and compliance requirements (SOC2, ISO 27001). +Configuration is via the Infrahub configuration file or environment variables and supports multiple destinations, TCP or UDP transport, optional TLS, and both RFC 5424 and RFC 3164 formats, covering common SOC2 and ISO 27001 logging requirements. + +**Learn more:** [Log forwarding](../../deploy-manage/run-observe/log-forwarding/overview.mdx) ### Modular GraphQL queries with reusable fragments @@ -95,17 +97,21 @@ query DeviceDetails($name: String!) { Transitive dependencies are resolved automatically - a fragment that spreads another fragment brings both along. Unresolvable references fail at sync time with an actionable error identifying the query and fragment. The same rendering applies when executing queries locally via `infrahubctl`, so IDE workflows keep working. +**Learn more:** [GraphQL fragments](../../development-resources/graphql-fragments.mdx) + ### Artifact content composition Jinja2 Transformations gain a new set of filters for composing an artifact from the content of other artifacts: - `artifact_content` takes a `storage_id` and returns the rendered content of another artifact as a string. - `file_object_content` does the same for a `CoreFileObject`. -- `file_object_content_by_hfid` takes the `hfid` of a `CoreFileObject` and returns the content as a string +- `file_object_content_by_hfid` takes the human-friendly ID (`hfid`) of a `CoreFileObject` and returns the content as a string - `file_object_content_by_id` takes the `id` of a `CoreFileObject` and returns the content as a string - `from_json` and `from_yaml` parse the inlined content so the composing template can traverse it as structured data. -This lets a template inline and parse sections from other artifacts without duplicating the template logic that produced them. Python Transformations can achieve the same by calling `object_store.get()` via the SDK. +A template can inline and parse sections from other artifacts without duplicating the template logic that produced them. Python Transformations can achieve the same by calling `object_store.get()` via the SDK. + +**Learn more:** [Artifact content composition](../../artifacts/content-composition.mdx) ### Display options for attributes and relationships @@ -125,6 +131,8 @@ attributes: The IPAM detail pages have been unified with the standard object-details card in the same pass, picking up field metadata, the **Extra** toggle, metadata editing, and profiles & groups tabs. +**Learn more:** [Field visibility](../../schema/field-visibility.mdx) + ### Delete branch after merge Merged branches can now be cleaned up automatically. Two opt-in configuration flags control the behaviour: @@ -138,17 +146,19 @@ The `BranchDelete` GraphQL mutation accepts a `delete_from_git` parameter to ove ### Custom HTTP headers on webhooks -Webhooks can now carry arbitrary custom HTTP headers - the most common use case being authentication to target systems that require `Authorization: Bearer ` or similar. +Webhooks can now send arbitrary custom HTTP headers - the most common use case being authentication to target systems that require `Authorization: Bearer ` or similar. A single header can be attached to multiple webhooks, so rotating a credential in one place propagates to all of them on the next event. Two new node kinds back this, both implementing a shared `CoreKeyValue` generic: - **`CoreStaticKeyValue`** - the value is stored as-is in Infrahub. Appropriate for system identifiers, tenant IDs, or tokens that don't need external secret management. -- **`CoreEnvKeyValue`** - only the environment-variable **name** is stored; the actual value is resolved from the worker process environment at send time. The stored configuration never contains the secret, which lets secret managers (Kubernetes secrets, Vault, Delinea) inject credentials the usual way. Missing variables are logged with a warning and the header is skipped; the remainder of the request is sent intact. +- **`CoreEnvKeyValue`** - only the environment-variable **name** is stored; the actual value is resolved from the worker process environment at send time. The stored configuration never contains the secret, so secret managers (Kubernetes secrets, Vault, Delinea) can inject credentials through their normal mechanism. Missing variables are logged with a warning and the header is skipped; the remainder of the request is sent intact. If a custom header collides with a system-reserved header name (for example: `Content-Type`), the user's value wins. Although shipped to solve the webhook-authentication use case, `CoreKeyValue` and its two implementations are not webhook-specific - they can be reused anywhere in other contexts as well. +**Learn more:** [Webhooks](../../webhooks/overview.mdx) + ### IPAM: closest parent prefix lookup in search The search-anywhere dialog now understands IP addresses and CIDR prefixes. Typing `10.1.2.45` returns: @@ -189,7 +199,7 @@ The Accounts, Groups, Roles, and Global Permissions tables in Role Manager now s ### Namespace restrictions on generics -Generic schemas now accept a namespace restriction parameter that limits which namespaces can inherit from them. This is enforced at schema-load time. **This affects existing extensions of `CoreGenericRepository` and `CoreWebhook`** - see the Breaking changes section below. +Generic schemas now accept a namespace restriction parameter that limits which namespaces can inherit from them. Use it to prevent other namespaces from extending a core generic unintentionally. This is enforced at schema-load time. **This affects existing extensions of `CoreGenericRepository` and `CoreWebhook`** - see the Breaking changes section below. ### Computed attributes: local execution @@ -203,7 +213,9 @@ Concretely: - Each local-change mutation emits **one** consolidated event/webhook containing both the original change and the updated computed attribute, instead of two separate events. - **Remote** changes (peer-node updates that affect computed attributes on other nodes) continue to use the existing background-task path - no behavioural change there. Python Transform-based computed attributes are also unchanged. -This eliminates the single most common source of trivial background-task load in large imports and bulk edits. +This eliminates the single most common source of background-task load from trivial local changes in large imports and bulk edits. + +**Learn more:** [Computed attributes](../../computed-attributes/overview.mdx) ### Schema selector: sticky search and expand/collapse @@ -269,7 +281,7 @@ Before you upgrade an instance of Infrahub, we strongly advise you to delete bra **Please** read the Breaking changes section above before starting the upgrade. In particular, confirm you have no schema extensions inheriting from `CoreGenericRepository` or `CoreWebhook`, and that no client code still references the removed GraphQL queries or `_updated_at` field. -**Please** make sure to upgrade any existing installations of the infrahub-sdk to v``. +**Please** make sure to upgrade any existing installations of the infrahub-sdk to v`1.20.0`. **Please** make sure to backup your instance of Infrahub and make sure you are familiar with and have tested the restore procedure. For more information visit https://docs.infrahub.app/backup diff --git a/docs/docs/schema/nodes-and-attributes.mdx b/docs/docs/schema/nodes-and-attributes.mdx index 36b92428c63..080d67ddcf8 100644 --- a/docs/docs/schema/nodes-and-attributes.mdx +++ b/docs/docs/schema/nodes-and-attributes.mdx @@ -76,8 +76,9 @@ The `kind` of a model is generated by concatenating the `namespace` and the `nam - `Color`: An HTML color - `Boolean`: Flag that can be either True or False - `Bandwidth`: Bandwidth in kbps -- `IPHost`: IP Address in either IPV4 or IPv6 format +- `IPHost`: IP Address in either IPV4 or IPv6 format, stored with a prefix length (`192.0.2.1` is stored as `192.0.2.1/32`) - `IPNetwork`: IP Network in either IPV4 or IPv6 format +- `IPAddress`: A bare IP Address in either IPv4 or IPv6 format, without a prefix length or netmask. Use this instead of `IPHost` when the prefix is not part of the data, such as a DNS record target or a syslog destination. A value carrying a prefix or mask is rejected, and values sort lexically rather than numerically - `Checkbox`: Duplicate of `Boolean` - `List`: List of any value - `JSON`: Any data structure compatible with JSON @@ -105,6 +106,7 @@ The `kind` of a model is generated by concatenating the `namespace` and the `nam | `Bandwidth` | Yes | Yes | | `IPHost` | Yes | Yes | | `IPNetwork` | Yes | Yes | + | `IPAddress` | Yes | Yes | | `Checkbox` | No | Yes | | `List` | No | Yes | | `JSON` | No | Yes | diff --git a/docs/sidebars.ts b/docs/sidebars.ts index f9558759482..741c762226b 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -566,6 +566,7 @@ const sidebars: SidebarsConfig = { 'reference/infrahub-cli/infrahub-server', 'reference/infrahub-cli/infrahub-dev', 'reference/infrahub-cli/infrahub-upgrade', + 'reference/infrahub-cli/infrahub-recover', ], }, { diff --git a/frontend/app/.betterer.results b/frontend/app/.betterer.results index dba0da29201..c200e5cfb72 100644 --- a/frontend/app/.betterer.results +++ b/frontend/app/.betterer.results @@ -69,14 +69,14 @@ exports[`fix ts error`] = { "src/entities/navigation/ui/search-anywhere/search-nodes.tsx:3544903153": [ [127, 62, 4, "tsc: Property \'node\' does not exist on type \'NodeAttributeWithMetadata | NodeRelationshipManyWithMetadata | NodeRelationshipOneWithMetadata | string | string[]\'.\\n Property \'node\' does not exist on type \'string\'.", "2087865285"], [136, 16, 5, "tsc: Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "183222373"], - [137, 29, 4, "tsc: Property \'kind\' does not exist on type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; isRelationship: boolean; paginated: boolean; } | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; excluded_values?: string | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; state: \\"present\\" | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { label: string; name: string; }\'.\\n Property \'kind\' does not exist on type \'{ label: string; name: string; }\'.", "2088042925"], + [137, 29, 4, "tsc: Property \'kind\' does not exist on type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; isRelationship: boolean; paginated: boolean; } | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; excluded_values?: string | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; state: \\"present\\" | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { label: string; name: string; }\'.\\n Property \'kind\' does not exist on type \'{ label: string; name: string; }\'.", "2088042925"], [138, 16, 5, "tsc: Type \'NodeAttributeWithMetadata | NodeCorePropertyValue | NodeRelationshipWithMetadata\' is not assignable to type \'boolean | null; label: string; color: string; } | null; } | number | { edges: { node: NodeCore; }[]; } | { node: NodeCore; } | { value: string | { value: string\'.\\n Type \'undefined\' is not assignable to type \'boolean | null; label: string; color: string; } | null; } | number | { edges: { node: NodeCore; }[]; } | { node: NodeCore; } | { value: string | { value: string\'.", "189936718"] ], "src/entities/nodes/convert/ui/convert-form.tsx:1982113281": [ [41, 6, 8, "tsc: Type \'{}\' is not assignable to type \'Record\'.\\n Index signature for type \'string\' is missing in type \'{}\'.", "1301887866"] ], - "src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx:2811726414": [ - [114, 25, 9, "tsc: Argument of type \'\\"NodeKind\\"\' is not assignable to parameter of type \'never\'.", "2512581423"] + "src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx:2939241983": [ + [115, 25, 9, "tsc: Argument of type \'\\"NodeKind\\"\' is not assignable to parameter of type \'never\'.", "2512581423"] ], "src/entities/nodes/object/ui/object-details/action-buttons/details-buttons.tsx:1531921325": [ [54, 21, 25, "tsc: \'objectDetailsData.targets\' is possibly \'null\' or \'undefined\'.", "1788660526"], @@ -107,9 +107,9 @@ exports[`fix ts error`] = { "src/entities/nodes/relationships/ui/queries/get-default-parent.query.ts:1805247483": [ [18, 34, 2, "tsc: Property \'id\' does not exist on type \'NodeCore | NodeCore[] | { from_pool: { id: string; }; }\'.\\n Property \'id\' does not exist on type \'NodeCore[]\'.", "5861160"] ], - "src/entities/nodes/relationships/ui/relationship-combobox-list.tsx:1898308306": [ - [69, 59, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], - [76, 41, 4, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2087865285"] + "src/entities/nodes/relationships/ui/relationship-combobox-list.tsx:2042096044": [ + [76, 59, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], + [83, 41, 4, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2087865285"] ], "src/entities/nodes/relationships/ui/relationship-hierarchical-combobox-list.tsx:1694393601": [ [156, 61, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], @@ -131,7 +131,7 @@ exports[`fix ts error`] = { [93, 52, 4, "tsc: Binding element \'node\' implicitly has an \'any\' type.", "2087865285"] ], "src/entities/repository/ui/repository-objects-manager.tsx:3064139168": [ - [40, 6, 18, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hierarchical: boolean; generate_profile: boolean; used_by?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; restricted_namespaces?: string[] | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'ModelSchema\'.\\n Type \'null\' is not assignable to type \'ModelSchema\'.", "3283107600"] + [40, 6, 18, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hierarchical: boolean; generate_profile: boolean; used_by?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; restricted_namespaces?: string[] | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'ModelSchema\'.\\n Type \'null\' is not assignable to type \'ModelSchema\'.", "3283107600"] ], "src/entities/resource-manager/ui/number-pool-form.tsx:3337327002": [ [50, 39, 13, "tsc: Argument of type \'NodeFieldsWithMetadata | undefined\' is not assignable to parameter of type \'Record | undefined\'.\\n Type \'NodeFieldsWithMetadata\' is not assignable to type \'Record\'.\\n \'string\' index signatures are incompatible.\\n Type \'NodeAttributeWithMetadata | NodeCorePropertyValue | NodeRelationshipWithMetadata\' is not assignable to type \'NodeAttributeWithMetadata\'.\\n Type \'undefined\' is not assignable to type \'NodeAttributeWithMetadata\'.", "3940586649"], @@ -258,9 +258,9 @@ exports[`fix ts error`] = { "src/shared/components/errors/error-boundary-app.tsx:3236611445": [ [5, 24, 5, "tsc: Type \'unknown\' is not assignable to type \'Error\'.", "165548477"] ], - "src/shared/components/form/dynamic-form.tsx:3819858559": [ + "src/shared/components/form/dynamic-form.tsx:3408699566": [ [98, 14, 11, "tsc: Type \'\\"setValueAs\\" | \\"setValueAs\\" | \\"valueAsDate\\"> | \\"valueAsDate\\"> | \\"valueAsNumber\\" | \\"valueAsNumber\\" | undefined; defaultValue?: FormAttributeValue | undefined; defaultValue?: FormAttributeValue | undefined; description?: string | undefined; description?: string | undefined; disabled?: boolean | undefined; disabled?: boolean | undefined; isBulkUpdate?: boolean | undefined; isBulkUpdate?: boolean | undefined; label?: string | undefined; label?: string | undefined; name: string; placeholder?: string | undefined; name: string; placeholder?: string | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; pools?: NumberPool[] | undefined; rules?: Omit, \\"disabled\\" | undefined; rules?: Omit, \\"disabled\\" | undefined; shouldUnregister?: boolean | undefined; shouldUnregister?: boolean | undefined; unique?: boolean | undefined; unique?: boolean | undefined; } | undefined; } | undefined; } | undefined; } | { attribute?: AttributeSchema | { attribute?: AttributeSchema\' is not assignable to type \'IntrinsicAttributes & NumberFieldProps\'.\\n Type \'\\"setValueAs\\" | \\"valueAsDate\\"> | \\"valueAsNumber\\" | undefined; defaultValue?: FormAttributeValue | undefined; description?: string | undefined; disabled?: boolean | undefined; isBulkUpdate?: boolean | undefined; label?: string | undefined; name: string; placeholder?: string | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; rules?: Omit, \\"disabled\\" | undefined; shouldUnregister?: boolean | undefined; unique?: boolean | undefined; } | undefined; } | { attribute?: AttributeSchema\' is not assignable to type \'NumberFieldProps\'.\\n Types of property \'onChange\' are incompatible.\\n Type \'((value: FormFieldValue) => void) | undefined\' is not assignable to type \'ChangeEventHandler | undefined\'.\\n Type \'(value: FormFieldValue) => void\' is not assignable to type \'ChangeEventHandler\'.\\n Types of parameters \'value\' and \'event\' are incompatible.\\n Type \'ChangeEvent\' is not assignable to type \'FormFieldValue\'.", "588154884"], - [148, 25, 5, "tsc: Argument of type \'DynamicKindFieldProps\' is not assignable to parameter of type \'never\'.", "187023499"] + [149, 25, 5, "tsc: Argument of type \'DynamicKindFieldProps\' is not assignable to parameter of type \'never\'.", "187023499"] ], "src/shared/components/form/fields/color.field.tsx:220004711": [ [9, 17, 15, "tsc: Interface \'InputFieldProps\' cannot simultaneously extend types \'FormFieldProps\' and \'\\"name\\"> | Omit | Omit | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2303311585"], + [33, 4, 12, "tsc: Type \'{ kind: string; }\' is not assignable to type \'ModelSchema\'.\\n Type \'{ kind: string; }\' is missing 4 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2303311585"], [56, 64, 21, "tsc: Argument of type \'NodeCore[] | PoolSource | TemplateSource | null | null | null | undefined; display_label: string | undefined; value: { id: NodeCore | undefined; }; } | { from_pool: { id: string; }; } | { source: ProfileSource | { type: SourceType; } | { type: SourceType; }\' is not assignable to parameter of type \'FormRelationshipValue | undefined\'.\\n Type \'NodeCore[] | PoolSource | TemplateSource | null | null | null | undefined; display_label: string | undefined; value: { id: NodeCore | undefined; }; } | { from_pool: { id: string; }; } | { source: ProfileSource | { type: SourceType; } | { type: SourceType; }\' is not assignable to type \'EmptyFieldValue | RelationshipManyValueFromProfile | RelationshipManyValueFromTemplate | RelationshipManyValueFromUser | RelationshipOneValueFromProfile | RelationshipOneValueFromTemplate | RelationshipOneValueFromUser | RelationshipValueFromPool\'.\\n Type \'NodeCore[] | PoolSource | TemplateSource | null | null | null | undefined; display_label: string | undefined; value: { id: NodeCore | undefined; }; } | { from_pool: { id: string; }; } | { source: ProfileSource | { type: SourceType; } | { type: SourceType; }\' is not assignable to type \'RelationshipManyValueFromProfile\'.\\n Types of property \'source\' are incompatible.\\n Type \'PoolSource | ProfileSource | TemplateSource | null | undefined | { type: SourceType; } | { type: SourceType; }\' is not assignable to type \'ProfileSource\'.\\n Type \'undefined\' is not assignable to type \'ProfileSource\'.", "2333515987"], [63, 48, 13, "tsc: Property \'display_label\' does not exist on type \'NodeCore | { from_pool: { id: string; }; }\'.\\n Property \'display_label\' does not exist on type \'{ from_pool: { id: string; }; }\'.", "1907695430"], [84, 18, 4, "tsc: Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2088092007"] @@ -292,11 +292,11 @@ exports[`fix ts error`] = { [74, 9, 21, "tsc: Property \'type\' is missing in type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }; filterQuery?: Record | \\"valueAsNumber\\" | boolean | null | null | null | null | null | null | null | null | null | null | null | number | string[]> | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; description?: string | undefined; disabled?: boolean | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isBulkUpdate?: boolean | undefined; name: string; label?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; optional: boolean; branch?: \\"local\\" | undefined; options?: SelectOption[] | undefined; order_weight?: number | undefined; parent?: string | undefined; peer?: string | undefined; peerField?: string | undefined; placeholder?: string | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; relationship: { id?: string | undefined; rules?: Omit, \\"disabled\\" | undefined; shouldUnregister?: boolean | undefined; state: \\"present\\" | undefined; } | undefined; } | { unique: true; defaultValue?: FormRelationshipValue\' but required in type \'DynamicRelationshipFieldProps\'.", "2975306552"], [86, 9, 21, "tsc: Property \'type\' is missing in type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }; filterQuery?: Record | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; description?: string | undefined; disabled?: boolean | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isBulkUpdate?: boolean | undefined; name: string; label?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; optional: boolean; branch?: \\"local\\" | undefined; options?: SelectOption[] | undefined; order_weight?: number | undefined; parent?: string | undefined; peer?: string | undefined; peerField?: string | undefined; placeholder?: string | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; relationship: { id?: string | undefined; shouldUnregister?: boolean | undefined; state: \\"present\\" | undefined; unique?: boolean | undefined; } | undefined; } | { rules: { required: true; }; defaultValue?: FormRelationshipValue\' but required in type \'DynamicRelationshipFieldProps\'.", "2975306552"] ], - "src/shared/components/form/fields/relationships/generic-relationship.field.tsx:3433690233": [ - [222, 56, 4, "tsc: Property \'name\' does not exist on type \'\\"\\" | \\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; } | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; state: \\"present\\" | { id?: string\'.\\n Property \'name\' does not exist on type \'\\"\\"\'.", "2087876002"] + "src/shared/components/form/fields/relationships/generic-relationship.field.tsx:39778637": [ + [231, 54, 4, "tsc: Property \'name\' does not exist on type \'\\"\\" | \\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; } | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; state: \\"present\\" | { id?: string\'.\\n Property \'name\' does not exist on type \'\\"\\"\'.", "2087876002"] ], "src/shared/components/form/file-with-profile-form.tsx:3973907063": [ - [13, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'.\\n Type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is missing 2 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2166186324"] + [13, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'.\\n Type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is missing 2 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2166186324"] ], "src/shared/components/form/generic-object-form.tsx:1723799544": [ [14, 4, 69, "tsc: Argument of type \'null | string | undefined\' is not assignable to parameter of type \'(() => string | null | null) | string\'.\\n Type \'undefined\' is not assignable to type \'(() => string | null | null) | string\'.", "2587726796"] @@ -306,10 +306,10 @@ exports[`fix ts error`] = { [87, 8, 5, "tsc: Type \'(null | undefined; badge: string; } | { value: string; label: string | { value: string; label: string; })[]\' is not assignable to type \'DropdownOption[]\'.\\n Type \'null | undefined; badge: string; } | { value: string; label: string | { value: string; label: string; }\' is not assignable to type \'DropdownOption\'.\\n Type \'{ value: string; label: null | string | undefined; badge: string; }\' is not assignable to type \'DropdownOption\'.\\n Types of property \'label\' are incompatible.\\n Type \'string | null | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "179721187"] ], "src/shared/components/form/node-with-profile-form.tsx:1982864313": [ - [12, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'.\\n Type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is missing 2 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2166186324"] + [12, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'.\\n Type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is missing 2 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2166186324"] ], "src/shared/components/form/utils/shouldAllowEmptySubmission.test.ts:2916106780": [ - [61, 19, 44, "tsc: Conversion of type \'{ attributes: never[]; }\' to type \'ModelSchema\' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to \'unknown\' first.\\n Type \'{ attributes: never[]; }\' is missing 5 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "1893999184"] + [61, 19, 44, "tsc: Conversion of type \'{ attributes: never[]; }\' to type \'ModelSchema\' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to \'unknown\' first.\\n Type \'{ attributes: never[]; }\' is missing 5 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "1893999184"] ], "src/shared/components/form/utils/updateFormFieldValue.ts:2093845453": [ [45, 30, 8, "tsc: Argument of type \'null | { id: string; } | { id: string; }[]\' is not assignable to parameter of type \'NodeCore | NodeCore[] | boolean | null | number | string | string[]\'.\\n Type \'{ id: string; }\' is not assignable to type \'NodeCore | NodeCore[] | boolean | null | number | string | string[]\'.\\n Property \'__typename\' is missing in type \'{ id: string; }\' but required in type \'NodeCore\'.", "288015442"], @@ -327,10 +327,10 @@ exports[`fix ts error`] = { "src/shared/components/inputs/peer.tsx:3252865674": [ [66, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] ], - "src/shared/components/inputs/relationship-one.tsx:3500924605": [ - [97, 38, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], - [99, 54, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], - [146, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node | PoolValue | null\'.\\n Type \'NodeCore\' is not assignable to type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] + "src/shared/components/inputs/relationship-one.tsx:798377139": [ + [100, 38, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], + [102, 54, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], + [150, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node | PoolValue | null\'.\\n Type \'NodeCore\' is not assignable to type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] ], "src/shared/components/table/table.tsx:3990914339": [ [63, 40, 23, "tsc: Argument of type \'number | string | tRowValue | undefined\' is not assignable to parameter of type \'number | string | tRowValue\'.\\n Type \'undefined\' is not assignable to type \'number | string | tRowValue\'.", "1426455104"], diff --git a/frontend/app/package.json b/frontend/app/package.json index c370508c22b..c1199d5c803 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -32,7 +32,6 @@ "check:error-bindings": "node scripts/generate-error-bindings.mjs --check" }, "dependencies": { - "@apollo/client": "3.13.8", "@codemirror/commands": "^6.10.4", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.4", @@ -58,8 +57,9 @@ "@tanstack/react-query-devtools": "^5.101.1", "@tanstack/react-table": "^8.21.3", "@uiw/react-color": "^2.10.3", + "@urql/core": "^6.0.3", + "@urql/exchange-auth": "^3.0.0", "@xyflow/react": "^12.11.1", - "apollo-upload-client": "18.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cm6-graphql": "^0.2.1", @@ -114,7 +114,6 @@ "@playwright/test": "1.61.1", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "catalog:", - "@types/apollo-upload-client": "18.0.1", "@types/dagre": "^0.7.54", "@types/node": "catalog:", "@types/prismjs": "^1.26.6", diff --git a/frontend/app/src/app/app.tsx b/frontend/app/src/app/app.tsx index 3e16246f036..64509dc5c52 100644 --- a/frontend/app/src/app/app.tsx +++ b/frontend/app/src/app/app.tsx @@ -1,4 +1,3 @@ -import { ApolloProvider } from "@apollo/client"; import { addCollection } from "@iconify-icon/react"; import mdiIcons from "@iconify-json/mdi/icons.json" with { type: "json" }; import { QueryClientProvider } from "@tanstack/react-query"; @@ -10,7 +9,6 @@ import { RouterProvider } from "react-router"; import { TanStackQueryDevtools } from "@/app/devtools"; import { router } from "@/app/router"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { queryClient } from "@/shared/api/rest/client"; import { ErrorBoundaryApp } from "@/shared/components/errors/error-boundary-app"; import { store } from "@/shared/stores"; @@ -30,15 +28,13 @@ export function App() { - - - - - - - - - + + + + + + + diff --git a/frontend/app/src/entities/authentication/domain/use-cases/refresh-access-token.ts b/frontend/app/src/entities/authentication/domain/use-cases/refresh-access-token.ts index 38c2bd1222e..9ebbe79f227 100644 --- a/frontend/app/src/entities/authentication/domain/use-cases/refresh-access-token.ts +++ b/frontend/app/src/entities/authentication/domain/use-cases/refresh-access-token.ts @@ -9,12 +9,6 @@ import { export type RefreshAccessToken = () => Promise; -// Throws on every failure mode (missing refresh token, API error). The caller -// is responsible for handling the failure — `retryWithRefreshedToken` in -// graphqlClientApollo.tsx catches the rejection and calls `redirectToLogin`. -// Previously this function did its own `window.location.reload()`, which -// dropped in-flight React Query state and double-navigated when the catch -// site also redirected. export const refreshAccessToken: RefreshAccessToken = async () => { const refreshToken = getRefreshToken(); diff --git a/frontend/app/src/entities/branches/api/create-branch-from-api.ts b/frontend/app/src/entities/branches/api/create-branch-from-api.ts index 7ecd3e4936f..52779b4e1a9 100644 --- a/frontend/app/src/entities/branches/api/create-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/create-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const BRANCH_CREATE = graphql(` mutation BRANCH_CREATE($name: String!, $description: String, $sync_with_git: Boolean) { diff --git a/frontend/app/src/entities/branches/api/delete-branch-from-api.ts b/frontend/app/src/entities/branches/api/delete-branch-from-api.ts index c9940ac9ad5..c9ab7222904 100644 --- a/frontend/app/src/entities/branches/api/delete-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/delete-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const BRANCH_DELETE = graphql(` mutation BRANCH_DELETE($name: String, $deleteFromGit: Boolean) { diff --git a/frontend/app/src/entities/branches/api/get-branch-action-state-from-api.ts b/frontend/app/src/entities/branches/api/get-branch-action-state-from-api.ts index efdbbbf9ed3..a303e60de8e 100644 --- a/frontend/app/src/entities/branches/api/get-branch-action-state-from-api.ts +++ b/frontend/app/src/entities/branches/api/get-branch-action-state-from-api.ts @@ -1,7 +1,5 @@ -import { graphql } from "gql.tada"; - +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { StateType } from "@/shared/api/graphql/generated/types"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; const GET_BRANCH_ACTION_STATE = graphql(` query GET_BRANCH_ACTION_STATE($branch: String!, $workflow: [String], $state: [StateType]) { @@ -25,6 +23,5 @@ export function getBranchActionStateFromApi(params: GetBranchActionStateFromApiP workflow: [...params.workflow], state: [...params.state], }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/branches/api/get-branch-details-from-api.ts b/frontend/app/src/entities/branches/api/get-branch-details-from-api.ts index c035728e116..993fb97c922 100644 --- a/frontend/app/src/entities/branches/api/get-branch-details-from-api.ts +++ b/frontend/app/src/entities/branches/api/get-branch-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const GET_BRANCH_DETAILS = graphql(` diff --git a/frontend/app/src/entities/branches/api/get-branches-count-from-api.ts b/frontend/app/src/entities/branches/api/get-branches-count-from-api.ts index d652b75fe1d..266b284f58a 100644 --- a/frontend/app/src/entities/branches/api/get-branches-count-from-api.ts +++ b/frontend/app/src/entities/branches/api/get-branches-count-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_BRANCHES_COUNT = graphql(` query GetBranchesCount($nameValue: String, $partialMatch: Boolean, $statusValue: BranchStatus, $createdById: ID, $branchedFromAfter: DateTime, $branchedFromBefore: DateTime, $createdAtAfter: DateTime, $createdAtBefore: DateTime, $updatedAtAfter: DateTime, $updatedAtBefore: DateTime) { diff --git a/frontend/app/src/entities/branches/api/get-branches-from-api.ts b/frontend/app/src/entities/branches/api/get-branches-from-api.ts index 96f69ab0967..5b4c052ab30 100644 --- a/frontend/app/src/entities/branches/api/get-branches-from-api.ts +++ b/frontend/app/src/entities/branches/api/get-branches-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const BRANCHES_PER_PAGE = 40; diff --git a/frontend/app/src/entities/branches/api/merge-branch-from-api.ts b/frontend/app/src/entities/branches/api/merge-branch-from-api.ts index 73235dbe194..29d5f65afa7 100644 --- a/frontend/app/src/entities/branches/api/merge-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/merge-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const BRANCH_MERGE = graphql(` mutation BRANCH_MERGE($name: String) { diff --git a/frontend/app/src/entities/branches/api/rebase-branch-from-api.ts b/frontend/app/src/entities/branches/api/rebase-branch-from-api.ts index 042190b2fde..1c489dfbf42 100644 --- a/frontend/app/src/entities/branches/api/rebase-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/rebase-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const BRANCH_REBASE = graphql(` mutation BRANCH_REBASE($name: String, $waitUntilCompletion: Boolean!) { diff --git a/frontend/app/src/entities/branches/api/validate-branch-from-api.ts b/frontend/app/src/entities/branches/api/validate-branch-from-api.ts index 4be43b33361..6288ffdee76 100644 --- a/frontend/app/src/entities/branches/api/validate-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/validate-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const BRANCH_VALIDATE = graphql(` mutation BRANCH_VALIDATE($name: String) { diff --git a/frontend/app/src/entities/diff/api/get-artifact-content-diff-from-api.ts b/frontend/app/src/entities/diff/api/get-artifact-content-diff-from-api.ts index 92246dd03c0..8e6583269e0 100644 --- a/frontend/app/src/entities/diff/api/get-artifact-content-diff-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-artifact-content-diff-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_ARTIFACT_THREADS = graphql(` query GET_ARTIFACT_THREADS($changeIds: [ID!]) { @@ -52,6 +50,5 @@ export function getArtifactContentDiffFromApi(params: GetArtifactContentDiffFrom variables: { changeIds: [params.proposedChangeId], }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/diff/api/get-check-details-from-api.ts b/frontend/app/src/entities/diff/api/get-check-details-from-api.ts index 6dfcbf080f7..8669fc56e55 100644 --- a/frontend/app/src/entities/diff/api/get-check-details-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-check-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_CHECK_DETAILS = graphql(` query GET_CHECK_DETAILS($id: ID!) { diff --git a/frontend/app/src/entities/diff/api/get-diff-comments-from-api.ts b/frontend/app/src/entities/diff/api/get-diff-comments-from-api.ts index b7518654fd7..87ec7746b7f 100644 --- a/frontend/app/src/entities/diff/api/get-diff-comments-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-diff-comments-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_OBJECT_THREAD_COMMENTS = graphql(` query GET_OBJECT_THREAD_COMMENTS($changeIds: [ID!], $objectPath: String) { @@ -50,6 +48,5 @@ export function getDiffCommentsFromApi(params: GetDiffCommentsFromApiParams) { changeIds: [params.proposedChangeId], objectPath: params.objectPath, }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/diff/api/get-diff-thread-from-api.ts b/frontend/app/src/entities/diff/api/get-diff-thread-from-api.ts index 83416646711..c8921e3e23b 100644 --- a/frontend/app/src/entities/diff/api/get-diff-thread-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-diff-thread-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_OBJECT_THREADS = graphql(` query GET_OBJECT_THREADS($changeIds: [ID!], $objectPath: String) { @@ -42,6 +40,5 @@ export function getDiffThreadFromApi(params: GetDiffThreadFromApiParams) { changeIds: [params.proposedChangeId], objectPath: params.objectPath, }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/diff/api/get-diff-tree-from-api.ts b/frontend/app/src/entities/diff/api/get-diff-tree-from-api.ts index 497ca017050..9093d2887bc 100644 --- a/frontend/app/src/entities/diff/api/get-diff-tree-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-diff-tree-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams, PaginationParams } from "@/shared/api/types"; const DIFF_TREE_QUERY = graphql(` diff --git a/frontend/app/src/entities/diff/api/get-diff-tree-summary-from-api.ts b/frontend/app/src/entities/diff/api/get-diff-tree-summary-from-api.ts index 2dfaa10d73c..4795951f622 100644 --- a/frontend/app/src/entities/diff/api/get-diff-tree-summary-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-diff-tree-summary-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_PROPOSED_CHANGES_DIFF_SUMMARY = graphql(` query GET_DIFF_TREE_SUMMARY($branch: String, $filters: DiffTreeQueryFilters, $proposedChangeId: String) { diff --git a/frontend/app/src/entities/diff/api/get-file-content-diff-from-api.ts b/frontend/app/src/entities/diff/api/get-file-content-diff-from-api.ts index 74929efdb56..52b913bea5e 100644 --- a/frontend/app/src/entities/diff/api/get-file-content-diff-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-file-content-diff-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_FILE_THREADS = graphql(` query GET_FILE_THREADS($changeIds: [ID!]) { @@ -60,6 +58,5 @@ export function getFileContentDiffFromApi(params: GetFileContentDiffFromApiParam variables: { changeIds: [params.proposedChangeId], }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/diff/api/get-validator-details-from-api.ts b/frontend/app/src/entities/diff/api/get-validator-details-from-api.ts index c83edf21754..a60bcda1470 100644 --- a/frontend/app/src/entities/diff/api/get-validator-details-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-validator-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_VALIDATOR_DETAILS = graphql(` query GET_VALIDATOR_DETAILS($ids: [ID!], $checksOffset: Int, $checksLimit: Int) { diff --git a/frontend/app/src/entities/diff/api/get-validators-from-api.ts b/frontend/app/src/entities/diff/api/get-validators-from-api.ts index b73e1682438..bc9b4c5cef0 100644 --- a/frontend/app/src/entities/diff/api/get-validators-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-validators-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_VALIDATORS = graphql(` query GET_CORE_VALIDATORS($id: ID!) { diff --git a/frontend/app/src/entities/diff/api/resolve-conflict-from-api.ts b/frontend/app/src/entities/diff/api/resolve-conflict-from-api.ts index 22eaed14df1..efbd0e0a22c 100644 --- a/frontend/app/src/entities/diff/api/resolve-conflict-from-api.ts +++ b/frontend/app/src/entities/diff/api/resolve-conflict-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const RESOLVE_CONFLICT = graphql(` mutation RESOLVE_CONFLICT($id: String, $selection: ConflictSelection) { diff --git a/frontend/app/src/entities/diff/api/run-check-from-api.ts b/frontend/app/src/entities/diff/api/run-check-from-api.ts index 38d0f3d88bc..c580ef744a6 100644 --- a/frontend/app/src/entities/diff/api/run-check-from-api.ts +++ b/frontend/app/src/entities/diff/api/run-check-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const RUN_CHECK = graphql(` mutation RUN_CHECK($proposedChangeId: String!, $checkType: CheckType) { diff --git a/frontend/app/src/entities/diff/api/update-diff-from-api.ts b/frontend/app/src/entities/diff/api/update-diff-from-api.ts index 706de572ce6..c2773282bf0 100644 --- a/frontend/app/src/entities/diff/api/update-diff-from-api.ts +++ b/frontend/app/src/entities/diff/api/update-diff-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const DIFF_UPDATE = graphql(` mutation DIFF_UPDATE($branchName: String!, $waitUntilCompletion: Boolean) { diff --git a/frontend/app/src/entities/events/api/get-events-from-api.ts b/frontend/app/src/entities/events/api/get-events-from-api.ts index 0ab5ee0268b..e8c2eced828 100644 --- a/frontend/app/src/entities/events/api/get-events-from-api.ts +++ b/frontend/app/src/entities/events/api/get-events-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import { DEFAULT_PAGE_SIZE } from "@/shared/utils/pagination"; const EVENTS_QUERY = graphql(` diff --git a/frontend/app/src/entities/generators/api/run-generator-from-api.ts b/frontend/app/src/entities/generators/api/run-generator-from-api.ts index 56e5f050378..cc5d09d0660 100644 --- a/frontend/app/src/entities/generators/api/run-generator-from-api.ts +++ b/frontend/app/src/entities/generators/api/run-generator-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const generatorRunMutation = graphql(` diff --git a/frontend/app/src/entities/groups/api/get-groups-from-api.ts b/frontend/app/src/entities/groups/api/get-groups-from-api.ts index 5a68a138a25..c02ff2c5051 100644 --- a/frontend/app/src/entities/groups/api/get-groups-from-api.ts +++ b/frontend/app/src/entities/groups/api/get-groups-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery, VariableType } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const getGroupsQuery = ({ objectKind }: { objectKind: string }) => { @@ -54,7 +53,7 @@ export function getGroupsFromApi({ branchName, atDate, }: GetGroupsFromApiParams) { - const query = gql(getGroupsQuery({ objectKind })); + const query = graphql(getGroupsQuery({ objectKind })); return graphqlClient.query({ query, diff --git a/frontend/app/src/entities/ipam/ip-addresses/api/get-ip-address-list-from-api.ts b/frontend/app/src/entities/ipam/ip-addresses/api/get-ip-address-list-from-api.ts index c5af82fc7dc..af61d975a35 100644 --- a/frontend/app/src/entities/ipam/ip-addresses/api/get-ip-address-list-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-addresses/api/get-ip-address-list-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, @@ -126,7 +125,7 @@ export function getIpAddressListWithAvailabilityFromApi({ const graphqlQuery = getIpAddressListWithAvailabilityGraphQLQuery(params); return graphqlClient.query({ - query: gql(graphqlQuery), + query: graphql(graphqlQuery), context: { branch: branchName, date: atDate, @@ -142,7 +141,7 @@ export function getIpAddressListWithoutAvailabilityFromApi({ const graphqlQuery = getIpAddressListWithoutAvailabilityGraphQLQuery(params); return graphqlClient.query({ - query: gql(graphqlQuery), + query: graphql(graphqlQuery), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/ipam/ip-addresses/api/get-next-ip-address-available-from-api.ts b/frontend/app/src/entities/ipam/ip-addresses/api/get-next-ip-address-available-from-api.ts index 1e7a308e0ba..1e41024ac46 100644 --- a/frontend/app/src/entities/ipam/ip-addresses/api/get-next-ip-address-available-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-addresses/api/get-next-ip-address-available-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const NEXT_IP_ADDRESS_QUERY = graphql(` diff --git a/frontend/app/src/entities/ipam/ip-namespaces/api/get-ip-namespace-list-from-api.ts b/frontend/app/src/entities/ipam/ip-namespaces/api/get-ip-namespace-list-from-api.ts index e0e0cfdd2c5..cf7ad0bc1c2 100644 --- a/frontend/app/src/entities/ipam/ip-namespaces/api/get-ip-namespace-list-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-namespaces/api/get-ip-namespace-list-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addFiltersToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams, PaginationParams } from "@/shared/api/types"; import { DEFAULT_PAGE_SIZE } from "@/shared/utils/pagination"; @@ -20,7 +19,7 @@ export async function getIpNamespaceListFromApi({ branchName, atDate, }: GetIpNamespaceListFromApiParams) { - const query = gql( + const query = graphql( jsonToGraphQLQuery({ query: { __name: `GetObjects${IP_NAMESPACE_GENERIC}`, diff --git a/frontend/app/src/entities/ipam/ip-prefixes/api/get-ip-prefix-list-from-api.ts b/frontend/app/src/entities/ipam/ip-prefixes/api/get-ip-prefix-list-from-api.ts index 4ee1ac5652f..22c19855cb6 100644 --- a/frontend/app/src/entities/ipam/ip-prefixes/api/get-ip-prefix-list-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-prefixes/api/get-ip-prefix-list-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, @@ -55,7 +54,7 @@ export async function getIpPrefixListFromApi({ relationships, }); - const query = gql(queryString); + const query = graphql(queryString); return graphqlClient.query({ query, context: { diff --git a/frontend/app/src/entities/ipam/ip-prefixes/api/get-next-ip-prefix-available-from-api.ts b/frontend/app/src/entities/ipam/ip-prefixes/api/get-next-ip-prefix-available-from-api.ts index 57db1d9e936..a977eb69267 100644 --- a/frontend/app/src/entities/ipam/ip-prefixes/api/get-next-ip-prefix-available-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-prefixes/api/get-next-ip-prefix-available-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const NEXT_IP_PREFIX_QUERY = graphql(` diff --git a/frontend/app/src/entities/ipam/ipam-tree/api/get-ipam-tree-nodes-by-parent-from-api.ts b/frontend/app/src/entities/ipam/ipam-tree/api/get-ipam-tree-nodes-by-parent-from-api.ts index fab6f12982a..72cced5ecf2 100644 --- a/frontend/app/src/entities/ipam/ipam-tree/api/get-ipam-tree-nodes-by-parent-from-api.ts +++ b/frontend/app/src/entities/ipam/ipam-tree/api/get-ipam-tree-nodes-by-parent-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams, PaginationParams } from "@/shared/api/types"; const GET_IPAM_TREE_NODES = graphql(` diff --git a/frontend/app/src/entities/navigation/api/search-from-api.ts b/frontend/app/src/entities/navigation/api/search-from-api.ts index 9bebea6f8df..ee35ca03e64 100644 --- a/frontend/app/src/entities/navigation/api/search-from-api.ts +++ b/frontend/app/src/entities/navigation/api/search-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const SEARCH = graphql(` diff --git a/frontend/app/src/entities/nodes/convert/api/convert-object-from-api.ts b/frontend/app/src/entities/nodes/convert/api/convert-object-from-api.ts index 1486a8559f2..d2b3423c479 100644 --- a/frontend/app/src/entities/nodes/convert/api/convert-object-from-api.ts +++ b/frontend/app/src/entities/nodes/convert/api/convert-object-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const CONVERT_OBJECT_MUTATION = graphql(` diff --git a/frontend/app/src/entities/nodes/convert/api/get-object-convert-fields-mapping-from-api.ts b/frontend/app/src/entities/nodes/convert/api/get-object-convert-fields-mapping-from-api.ts index 9c049eb036e..50774881c7e 100644 --- a/frontend/app/src/entities/nodes/convert/api/get-object-convert-fields-mapping-from-api.ts +++ b/frontend/app/src/entities/nodes/convert/api/get-object-convert-fields-mapping-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const GET_FIELDS_MAPPING = graphql(` diff --git a/frontend/app/src/entities/nodes/getObjectItemDisplayValue.tsx b/frontend/app/src/entities/nodes/getObjectItemDisplayValue.tsx index f150a98e452..444b1f19dcc 100644 --- a/frontend/app/src/entities/nodes/getObjectItemDisplayValue.tsx +++ b/frontend/app/src/entities/nodes/getObjectItemDisplayValue.tsx @@ -208,6 +208,7 @@ export const ObjectAttributeValue = ({ case ATTRIBUTE_KIND.FILE: case ATTRIBUTE_KIND.IP_HOST: case ATTRIBUTE_KIND.IP_NETWORK: + case ATTRIBUTE_KIND.IP_ADDRESS: case ATTRIBUTE_KIND.ANY: return {getTextValue(attributeData).toString()}; case ATTRIBUTE_KIND.URL: diff --git a/frontend/app/src/entities/nodes/hierarchy/api/get-object-ancestors-from-api.ts b/frontend/app/src/entities/nodes/hierarchy/api/get-object-ancestors-from-api.ts index 614c7295a71..cd795e5644d 100644 --- a/frontend/app/src/entities/nodes/hierarchy/api/get-object-ancestors-from-api.ts +++ b/frontend/app/src/entities/nodes/hierarchy/api/get-object-ancestors-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; export interface GetObjectAncestorsFromApiParams extends ContextParams { @@ -68,7 +67,7 @@ export const getObjectAncestorsFromApi = async ({ const query = getObjectAncestorsQuery({ objectKind, objectId }); return graphqlClient.query({ - query: gql(query), + query: graphql(query), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/hierarchy/api/get-tree-nodes-by-parent-from-api.ts b/frontend/app/src/entities/nodes/hierarchy/api/get-tree-nodes-by-parent-from-api.ts index d30e6bdbcfc..6e4e68fc0e6 100644 --- a/frontend/app/src/entities/nodes/hierarchy/api/get-tree-nodes-by-parent-from-api.ts +++ b/frontend/app/src/entities/nodes/hierarchy/api/get-tree-nodes-by-parent-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams, PaginationParams } from "@/shared/api/types"; export interface GetTreeNodesByParentQueryParams extends PaginationParams { @@ -49,7 +48,7 @@ export function GetTreeNodesByParentFromApi({ ...params }: GetTreeNodesByParentFromApiParams) { return graphqlClient.query({ - query: gql(GetTreeNodesByParentQuery(params)), + query: graphql(GetTreeNodesByParentQuery(params)), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/object/api/create-object-from-api.ts b/frontend/app/src/entities/nodes/object/api/create-object-from-api.ts index 9658cda0808..d0a7c55923d 100644 --- a/frontend/app/src/entities/nodes/object/api/create-object-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/create-object-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery, VariableType } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; export interface CreateObjectFromApiParams extends BranchContextParams { @@ -40,7 +39,7 @@ export function createObjectFromApi({ }); return graphqlClient.mutate({ - mutation: gql(mutation), + mutation: graphql(mutation), variables: file ? { file } : undefined, context: { branch: branchName, diff --git a/frontend/app/src/entities/nodes/object/api/delete-object-from-api.ts b/frontend/app/src/entities/nodes/object/api/delete-object-from-api.ts index 082f003465c..377b614ac72 100644 --- a/frontend/app/src/entities/nodes/object/api/delete-object-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/delete-object-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const getDeleteObjectQuery = (kind: string, objectId: string) => { @@ -26,7 +25,7 @@ export function deleteObjectFromApi({ atDate, }: ContextParams & { objectKind: string; objectId: string }) { return graphqlClient.mutate({ - mutation: gql(getDeleteObjectQuery(objectKind, objectId)), + mutation: graphql(getDeleteObjectQuery(objectKind, objectId)), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/object/api/delete-objects-from-api.ts b/frontend/app/src/entities/nodes/object/api/delete-objects-from-api.ts index 72a5380b677..3bb7cb1f2eb 100644 --- a/frontend/app/src/entities/nodes/object/api/delete-objects-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/delete-objects-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; export interface ObjectParam { @@ -42,7 +41,7 @@ export interface DeleteObjectsFromApiParams extends BranchContextParams { export function deleteObjectsFromApi({ objects, branchName, context }: DeleteObjectsFromApiParams) { return graphqlClient.mutate({ - mutation: gql(getDeleteObjectsQuery(objects)), + mutation: graphql(getDeleteObjectsQuery(objects)), context: { branch: branchName, ...context, diff --git a/frontend/app/src/entities/nodes/object/api/get-display-label-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-display-label-from-api.ts index cbbd610aa9c..d2444cfcc24 100644 --- a/frontend/app/src/entities/nodes/object/api/get-display-label-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-display-label-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const getNodeLabelQuery = ({ objectId, kind }: { objectId?: string | null; kind: string }) => { @@ -34,7 +33,7 @@ export function getNodeLabelFromApi({ kind: string; } & ContextParams) { return graphqlClient.query({ - query: gql(getNodeLabelQuery({ objectId, kind })), + query: graphql(getNodeLabelQuery({ objectId, kind })), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/object/api/get-node-metadata-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-node-metadata-from-api.ts index 16f6bec617a..7201f6127d0 100644 --- a/frontend/app/src/entities/nodes/object/api/get-node-metadata-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-node-metadata-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeMetadataFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import type { ContextParams } from "@/shared/api/types"; export interface GetNodeMetadataQueryParams { @@ -23,7 +22,7 @@ const getNodeMetadataQuery = ({ objectId, objectKind }: GetNodeMetadataQueryPara }, }; - return gql(jsonToGraphQLQuery(query)); + return graphql(jsonToGraphQLQuery(query)); }; export interface GetNodeMetadataFromApiParams extends ContextParams { diff --git a/frontend/app/src/entities/nodes/object/api/get-object-for-editing-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-object-for-editing-from-api.ts index 4bc6f0e5516..f95134c3cd0 100644 --- a/frontend/app/src/entities/nodes/object/api/get-object-for-editing-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-object-for-editing-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeCoreFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { addAttributesToRequest, addRelationshipsToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams } from "@/shared/api/types"; import { getRelationshipsForForm } from "@/shared/components/form/utils/getRelationshipsForForm"; @@ -76,11 +75,10 @@ export async function getObjectForEditingFromApi({ }); return graphqlClient.query({ - query: gql(queryString), + query: graphql(queryString), context: { branch: branchName, date: atDate, }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/nodes/object/api/get-object-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-object-from-api.ts index 12187c30d96..ac9c4827d83 100644 --- a/frontend/app/src/entities/nodes/object/api/get-object-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-object-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeCoreFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { addAttributesToRequest, addRelationshipsToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams } from "@/shared/api/types"; @@ -23,7 +22,7 @@ const getObjectQuery = ({ relationships, relationshipFragment, }: GetObjectQueryParams) => { - return gql( + return graphql( jsonToGraphQLQuery({ query: { __name: `GetObject${schemaKind}`, diff --git a/frontend/app/src/entities/nodes/object/api/get-objects-count-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-objects-count-from-api.ts index 275c367f67e..86a7f0b9673 100644 --- a/frontend/app/src/entities/nodes/object/api/get-objects-count-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-objects-count-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addFiltersToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams } from "@/shared/api/types"; @@ -25,7 +24,7 @@ const getObjectsCountQuery = ({ objectKind, filters }: getObjectsCountQueryParam }, }; - return gql(jsonToGraphQLQuery(query)); + return graphql(jsonToGraphQLQuery(query)); }; export interface GetObjectsCountFromApiParams extends ContextParams { diff --git a/frontend/app/src/entities/nodes/object/api/get-objects-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-objects-from-api.ts index 24665b4db40..b47558988b7 100644 --- a/frontend/app/src/entities/nodes/object/api/get-objects-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-objects-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeCoreFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { type AddAttributesToRequestOptions, addAttributesToRequest, @@ -39,7 +38,7 @@ const getObjectsQuery = ({ attributesOptions, relationshipsOptions, }: GetObjectsQueryParams) => { - return gql( + return graphql( jsonToGraphQLQuery({ query: { __name: `GetObjects${schemaKind}`, diff --git a/frontend/app/src/entities/nodes/object/api/update-object-from-api.ts b/frontend/app/src/entities/nodes/object/api/update-object-from-api.ts index f1afd2fb7b4..e66ce9c6547 100644 --- a/frontend/app/src/entities/nodes/object/api/update-object-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/update-object-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery, VariableType } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; import { RELATIONSHIP_BULK_ADD_PREFIX, @@ -123,7 +122,7 @@ export function updateObjectFromApi({ }); return graphqlClient.mutate({ - mutation: gql(mutation), + mutation: graphql(mutation), variables: file ? { file } : undefined, context: { branch: branchName, diff --git a/frontend/app/src/entities/nodes/object/domain/rules/get-attributes-visible-in-list-view.test.ts b/frontend/app/src/entities/nodes/object/domain/rules/get-attributes-visible-in-list-view.test.ts index 1a8b6815596..9236f87feb9 100644 --- a/frontend/app/src/entities/nodes/object/domain/rules/get-attributes-visible-in-list-view.test.ts +++ b/frontend/app/src/entities/nodes/object/domain/rules/get-attributes-visible-in-list-view.test.ts @@ -25,6 +25,7 @@ describe("getAttributesVisibleInListView", () => { generateAttributeSchema({ name: "bandwidth", kind: "Bandwidth", label: "Bandwidth" }), generateAttributeSchema({ name: "iphost", kind: "IPHost", label: "IPHost" }), generateAttributeSchema({ name: "ipnetwork", kind: "IPNetwork", label: "IPNetwork" }), + generateAttributeSchema({ name: "ipaddress", kind: "IPAddress", label: "IPAddress" }), generateAttributeSchema({ name: "checkbox", kind: "Checkbox", label: "Checkbox" }), generateAttributeSchema({ name: "list", kind: "List", label: "List" }), generateAttributeSchema({ name: "json", kind: "JSON", label: "JSON" }), @@ -49,6 +50,7 @@ describe("getAttributesVisibleInListView", () => { "Bandwidth", "IPHost", "IPNetwork", + "IPAddress", ]); }); diff --git a/frontend/app/src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx b/frontend/app/src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx index bb1d9519e5f..17c40f57ad6 100644 --- a/frontend/app/src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx +++ b/frontend/app/src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx @@ -47,6 +47,7 @@ export function DynamicFilterInput({ fieldSchema, value, onChange }: DynamicFilt case ATTRIBUTE_KIND.MAC_ADDRESS: case ATTRIBUTE_KIND.IP_HOST: case ATTRIBUTE_KIND.IP_NETWORK: + case ATTRIBUTE_KIND.IP_ADDRESS: case ATTRIBUTE_KIND.PASSWORD: case ATTRIBUTE_KIND.HASHED_PASSWORD: case ATTRIBUTE_KIND.URL: diff --git a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-attribute-cell.tsx b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-attribute-cell.tsx index 7c50ded83e5..4b94c172fa8 100644 --- a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-attribute-cell.tsx +++ b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-attribute-cell.tsx @@ -48,6 +48,7 @@ export function TableAttributeCell({ attributeSchema, attributeData }: TableAttr case ATTRIBUTE_KIND.FILE: case ATTRIBUTE_KIND.IP_HOST: case ATTRIBUTE_KIND.IP_NETWORK: + case ATTRIBUTE_KIND.IP_ADDRESS: case ATTRIBUTE_KIND.NODE_KIND: case ATTRIBUTE_KIND.TEXTAREA: { if (attributeSchema.name === "node_kind") { diff --git a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-column-header.tsx b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-column-header.tsx index 7b192d65fab..a8fac8c1470 100644 --- a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-column-header.tsx +++ b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-column-header.tsx @@ -229,7 +229,7 @@ function ColumnHeaderMenu({ {label} - + {activeSort && (activeSort.direction === SORT_DIRECTION.DESC ? ( <> diff --git a/frontend/app/src/entities/nodes/profiles/api/get-profiles-from-api.ts b/frontend/app/src/entities/nodes/profiles/api/get-profiles-from-api.ts index 3102ab2e61a..ac785413f00 100644 --- a/frontend/app/src/entities/nodes/profiles/api/get-profiles-from-api.ts +++ b/frontend/app/src/entities/nodes/profiles/api/get-profiles-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addRelationshipsToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams } from "@/shared/api/types"; @@ -45,7 +44,7 @@ export function getProfilesFromApi({ const getProfilesQueryString = buildGetProfilesQuery(profileSchemas); return graphqlClient.query({ - query: gql(getProfilesQueryString), + query: graphql(getProfilesQueryString), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/relationships/api/add-relationships-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/add-relationships-from-api.ts index 20ac8c3a681..13e9e7d0b92 100644 --- a/frontend/app/src/entities/nodes/relationships/api/add-relationships-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/add-relationships-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const ADD_RELATIONSHIP = graphql(` diff --git a/frontend/app/src/entities/nodes/relationships/api/get-default-parent-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-default-parent-from-api.ts index 434f0824cf3..4f269cd266b 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-default-parent-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-default-parent-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery, VariableType } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeCoreFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import type { ContextParams } from "@/shared/api/types"; import type { FormRelationshipValue } from "@/shared/components/form/type"; @@ -74,7 +73,7 @@ export const getDefaultParentFromApi = ({ return { data: null, error: null }; } - const query = gql( + const query = graphql( getRelationshipParent({ kind: parentRelationship?.peer, attribute: `${parentRelationshipAttribute?.name}__ids`, diff --git a/frontend/app/src/entities/nodes/relationships/api/get-object-relationships-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-object-relationships-from-api.ts index a9eeb62a4b2..314ce17be38 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-object-relationships-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-object-relationships-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, @@ -84,7 +83,7 @@ export const getObjectRelationshipsFromApi = ({ atDate, ...params }: GetObjectRelationshipsFromApiParams) => { - const query = gql(generateObjectRelationshipsQuery(params)); + const query = graphql(generateObjectRelationshipsQuery(params)); return graphqlClient.query({ query, diff --git a/frontend/app/src/entities/nodes/relationships/api/get-relationship-count-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-relationship-count-from-api.ts index f8faa995880..c63c46630d9 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-relationship-count-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-relationship-count-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; export type getRelationshipCountQueryParams = { @@ -35,7 +34,7 @@ const getRelationshipCountQuery = ({ }, }; - return gql(jsonToGraphQLQuery(query)); + return graphql(jsonToGraphQLQuery(query)); }; export interface GetRelationshipCountFromApiParams diff --git a/frontend/app/src/entities/nodes/relationships/api/get-relationship-properties-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-relationship-properties-from-api.ts index 20c620c5f7c..d9cc9190939 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-relationship-properties-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-relationship-properties-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; type GenerateObjectRelationshipsQueryParams = { parentKind: string; @@ -65,7 +64,7 @@ export const getRelationshipPropertiesFromApi = ({ atDate, ...params }: GetObjectRelationshipsFromApiParams) => { - const query = gql(generateRelationshipPropertiesQuery(params)); + const query = graphql(generateRelationshipPropertiesQuery(params)); return graphqlClient.query({ query, diff --git a/frontend/app/src/entities/nodes/relationships/api/get-relationships-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-relationships-from-api.ts index 2617467d899..98d3da6af16 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-relationships-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-relationships-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams, PaginationParams } from "@/shared/api/types"; type GenerateRelationshipListQueryParams = PaginationParams & { @@ -60,7 +59,9 @@ export const getRelationshipsFromApi = async ({ atDate, filterQuery, }: getRelationshipsFromApiParams) => { - const query = gql(generateRelationshipListQuery({ peer, limit, offset, search, filterQuery })); + const query = graphql( + generateRelationshipListQuery({ peer, limit, offset, search, filterQuery }) + ); return graphqlClient.query({ query, diff --git a/frontend/app/src/entities/nodes/relationships/api/remove-relationships-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/remove-relationships-from-api.ts index 14928b8a07c..41cc8e0de6e 100644 --- a/frontend/app/src/entities/nodes/relationships/api/remove-relationships-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/remove-relationships-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const REMOVE_RELATIONSHIP = graphql(` diff --git a/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx b/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx index 4dd367183f8..7ae3e12143d 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx @@ -5,16 +5,20 @@ import { useState } from "react"; import { SlideOverTitle } from "@/shared/components/display/slide-over"; import ObjectForm, { type ObjectFormProps } from "@/shared/components/form/object-form"; +import type { NodeFieldsWithMetadata } from "@/entities/nodes/object/domain/model/node"; import { useSchema } from "@/entities/schema/ui/hooks/useSchema"; export interface AddRelationshipActionProps { peer: string; onSuccess?: ObjectFormProps["onSuccess"]; + // Pre-fills the create form (e.g. the common_parent so a created peer satisfies the constraint). + initialObject?: NodeFieldsWithMetadata; } export const AddRelationshipAction: React.FC = ({ peer, onSuccess, + initialObject, }) => { const { schema } = useSchema(peer); const [open, setOpen] = useState(false); @@ -39,6 +43,7 @@ export const AddRelationshipAction: React.FC = ({ /> { setOpen(false); if (!onSuccess) return; diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx index 8b876e306ad..2926202becc 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx @@ -105,4 +105,34 @@ describe("RelationshipComboboxList", () => { const lastCall = useRelationshipsMock.mock.calls.at(-1)?.[0]; expect(lastCall.filterQuery).toEqual({ ids: ["17a4cdef-1234-4abc-8def-0123456789ab"] }); }); + + test("keeps the caller filterQuery on a UUID search when enforceFilterQueryOnIdSearch is set", async () => { + useRelationshipsMock.mockReturnValue(setupReturn()); + useSchemaMock.mockReturnValue({ schema: { label: "Device" } }); + + const component = await render( + + ); + + // Clear previous calls from initial render + useRelationshipsMock.mockClear(); + + const input = component.getByRole("combobox"); + await input.click(); + await input.fill("17a4cdef-1234-4abc-8def-0123456789ab"); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + // The id restriction and the enforced parent filter are both applied. + const lastCall = useRelationshipsMock.mock.calls.at(-1)?.[0]; + expect(lastCall.filterQuery).toEqual({ + device__ids: ["dev-1"], + ids: ["17a4cdef-1234-4abc-8def-0123456789ab"], + }); + }); }); diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx index f44720c7c51..6f382cf5419 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx @@ -24,6 +24,9 @@ export interface RelationshipComboboxListProps value?: RelationshipNode | null; filterItem?: (relationshipNode: RelationshipNode) => boolean; filterQuery?: Record; + // Keep filterQuery applied even on a UUID search. Used when the filter is a hard constraint + // (e.g. common_parent) that a UUID lookup must not bypass. + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipComboboxList = ({ @@ -33,19 +36,23 @@ export const RelationshipComboboxList = ({ onSelect, filterItem, filterQuery, + enforceFilterQueryOnIdSearch, ...props }: RelationshipComboboxListProps) => { const [search, setSearch] = React.useState(""); const { schema } = useSchema(peer); - // When the user types or pastes a UUID, switch the underlying query from a - // label search to an ids filter. UUID is a maximally specific match, so it - // intentionally overrides any caller-provided filterQuery. + // When the user types or pastes a UUID, switch the underlying query from a label search to an + // ids filter. UUID is a maximally specific match, so it overrides a caller-provided filterQuery + // by default — unless enforceFilterQueryOnIdSearch keeps the filter as a hard constraint. const isUuidSearch = search.length > 0 && isUuid(search); + const idSearchFilterQuery = enforceFilterQueryOnIdSearch + ? { ...filterQuery, ids: [search.trim()] } + : { ids: [search.trim()] }; const { isPending, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useRelationships({ peer, search: isUuidSearch ? undefined : search, - filterQuery: isUuidSearch ? { ids: [search.trim()] } : filterQuery, + filterQuery: isUuidSearch ? idSearchFilterQuery : filterQuery, }); if (error) return ; diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx index 5cca134ea40..934d933b8e7 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx @@ -20,6 +20,7 @@ import { inputStyle } from "@/shared/components/ui/style"; import { classNames } from "@/shared/utils/common"; import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; +import type { NodeFieldsWithMetadata } from "@/entities/nodes/object/domain/model/node"; import { getNodeLabel } from "@/entities/nodes/object/domain/rules/get-node-label"; import type { RelationshipNode } from "@/entities/nodes/relationships/domain/model/relationships"; import { AddRelationshipAction } from "@/entities/nodes/relationships/ui/add-relationship-action"; @@ -29,11 +30,28 @@ import { } from "@/entities/nodes/relationships/ui/relationship-combobox-list"; import { RelationshipHierarchicalComboboxList } from "@/entities/nodes/relationships/ui/relationship-hierarchical-combobox-list"; -export interface RelationshipHierarchicalContentProps extends RelationshipComboboxListProps {} +export interface RelationshipHierarchicalContentProps extends RelationshipComboboxListProps { + // The tree explorer browses the peer's own hierarchy and cannot honor an external filterQuery, + // so it is dropped when a filter must be enforced (e.g. common_parent). + hideExplore?: boolean; + // Pre-fills the "Add new" create form so a created peer satisfies an enforced filter. + addNewInitialObject?: NodeFieldsWithMetadata; +} export const RelationshipHierarchicalContent = ({ + hideExplore, + addNewInitialObject, ...props }: RelationshipHierarchicalContentProps) => { + if (hideExplore) { + return ( + + + + + ); + } + return ( @@ -44,7 +62,7 @@ export const RelationshipHierarchicalContent = ({ - + @@ -61,6 +79,10 @@ export interface RelationshipHierarchicalInputProps onChange?: (value: RelationshipNode | null) => void; value?: RelationshipNode | null; peer: string; + filterQuery?: Record; + hideExplore?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipHierarchicalInput = ({ @@ -68,6 +90,10 @@ export const RelationshipHierarchicalInput = ({ value, onChange, peer, + filterQuery, + hideExplore, + addNewInitialObject, + enforceFilterQueryOnIdSearch, ...props }: RelationshipHierarchicalInputProps) => { const [open, setOpen] = React.useState(false); @@ -83,7 +109,15 @@ export const RelationshipHierarchicalInput = ({ {value ? getNodeLabel(value) : ""} - + ); }; @@ -94,6 +128,10 @@ export interface RelationshipHierarchicalManyInputProps onChange: (value: RelationshipNode[]) => void; value?: RelationshipNode[] | null; peer: string; + filterQuery?: Record; + hideExplore?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipHierarchicalManyInput = ({ @@ -102,6 +140,10 @@ export const RelationshipHierarchicalManyInput = ({ onChange, peer, className, + filterQuery, + hideExplore, + addNewInitialObject, + enforceFilterQueryOnIdSearch, ...props }: RelationshipHierarchicalManyInputProps) => { const [open, setOpen] = React.useState(false); @@ -159,6 +201,10 @@ export const RelationshipHierarchicalManyInput = ({ peer={peer} onSelect={handleSelect} filterItem={(node) => !value?.some((v) => v.id === node.id)} + filterQuery={filterQuery} + hideExplore={hideExplore} + addNewInitialObject={addNewInitialObject} + enforceFilterQueryOnIdSearch={enforceFilterQueryOnIdSearch} /> ); diff --git a/frontend/app/src/entities/path-traversal/api/get-path-traversal-from-api.ts b/frontend/app/src/entities/path-traversal/api/get-path-traversal-from-api.ts index f92cf32374b..727a1c39583 100644 --- a/frontend/app/src/entities/path-traversal/api/get-path-traversal-from-api.ts +++ b/frontend/app/src/entities/path-traversal/api/get-path-traversal-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { GetPathTraversalParams, @@ -70,7 +69,7 @@ export async function getPathTraversalFromApi(params: GetPathTraversalParams) { }); return graphqlClient.query<{ InfrahubPathTraversal: PathTraversalResponse }>({ - query: gql(queryString), + query: graphql(queryString), context: { branch: branchName, date: atDate }, }); } diff --git a/frontend/app/src/entities/path-traversal/api/get-reachable-nodes-from-api.ts b/frontend/app/src/entities/path-traversal/api/get-reachable-nodes-from-api.ts index 82a4f18b000..5a4b8fb8fb0 100644 --- a/frontend/app/src/entities/path-traversal/api/get-reachable-nodes-from-api.ts +++ b/frontend/app/src/entities/path-traversal/api/get-reachable-nodes-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { GetReachableNodesParams, @@ -68,7 +67,7 @@ export async function getReachableNodesFromApi(params: GetReachableNodesParams) }); return graphqlClient.query<{ InfrahubReachableNodes: ReachableNodesResponse }>({ - query: gql(queryString), + query: graphql(queryString), context: { branch: branchName, date: atDate }, }); } diff --git a/frontend/app/src/entities/path-traversal/domain/model/path-traversal.ts b/frontend/app/src/entities/path-traversal/domain/model/path-traversal.ts index c0509e786cf..7d61b81e5e6 100644 --- a/frontend/app/src/entities/path-traversal/domain/model/path-traversal.ts +++ b/frontend/app/src/entities/path-traversal/domain/model/path-traversal.ts @@ -51,7 +51,7 @@ export type ReachableNodesResponse = { type ContextParams = { branchName?: string; - atDate?: Date | string | null; + atDate?: Date | null; }; export type GetPathTraversalParams = ContextParams & { diff --git a/frontend/app/src/entities/permission/api/get-global-permissions-from-api.ts b/frontend/app/src/entities/permission/api/get-global-permissions-from-api.ts index 7df9568fdd7..4850e6e8195 100644 --- a/frontend/app/src/entities/permission/api/get-global-permissions-from-api.ts +++ b/frontend/app/src/entities/permission/api/get-global-permissions-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_GLOBAL_PERMISSIONS = graphql(` query InfrahubGlobalPermissions { diff --git a/frontend/app/src/entities/permission/api/get-permissions-from-api.ts b/frontend/app/src/entities/permission/api/get-permissions-from-api.ts index ea510b44e34..710ff62ef23 100644 --- a/frontend/app/src/entities/permission/api/get-permissions-from-api.ts +++ b/frontend/app/src/entities/permission/api/get-permissions-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; export type GetPermissionsFromApiParams = ContextParams & { kind: string }; @@ -34,7 +33,7 @@ export const getPermissionsFromApi = ({ branchName, atDate, }: GetPermissionsFromApiParams) => { - const query = gql(getObjectPermissionsQuery(kind)); + const query = graphql(getObjectPermissionsQuery(kind)); return graphqlClient.query({ query, context: { diff --git a/frontend/app/src/entities/preferences/api/get-effective-preferences-from-api.ts b/frontend/app/src/entities/preferences/api/get-effective-preferences-from-api.ts index bd6bab5d609..1ee198e6e7d 100644 --- a/frontend/app/src/entities/preferences/api/get-effective-preferences-from-api.ts +++ b/frontend/app/src/entities/preferences/api/get-effective-preferences-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; // Preferences resolved user → global → default; each field carries its resolved value and source. const GET_EFFECTIVE_PREFERENCES = graphql(` diff --git a/frontend/app/src/entities/preferences/api/get-global-preferences-from-api.ts b/frontend/app/src/entities/preferences/api/get-global-preferences-from-api.ts index b22007b6dab..6fa68f2e63d 100644 --- a/frontend/app/src/entities/preferences/api/get-global-preferences-from-api.ts +++ b/frontend/app/src/entities/preferences/api/get-global-preferences-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_GLOBAL_PREFERENCES = graphql(` query InfrahubGlobalPreferences { diff --git a/frontend/app/src/entities/preferences/api/update-global-preference-from-api.ts b/frontend/app/src/entities/preferences/api/update-global-preference-from-api.ts index 5ca65061159..c21598859d0 100644 --- a/frontend/app/src/entities/preferences/api/update-global-preference-from-api.ts +++ b/frontend/app/src/entities/preferences/api/update-global-preference-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const UPDATE_GLOBAL_PREFERENCE = graphql(` mutation UpdateGlobalPreference($dateFormat: DateFormat, $timezone: String) { diff --git a/frontend/app/src/entities/preferences/api/upsert-user-preferences-from-api.ts b/frontend/app/src/entities/preferences/api/upsert-user-preferences-from-api.ts index 1d6a180513a..a50d5d7d3f5 100644 --- a/frontend/app/src/entities/preferences/api/upsert-user-preferences-from-api.ts +++ b/frontend/app/src/entities/preferences/api/upsert-user-preferences-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const UPSERT_USER_PREFERENCE = graphql(` mutation UpsertUserPreference($dateFormat: DateFormat, $timezone: String) { diff --git a/frontend/app/src/entities/proposed-changes/api/create-proposed-change-from-api.ts b/frontend/app/src/entities/proposed-changes/api/create-proposed-change-from-api.ts index 2141fba59c8..7ac778ef82d 100644 --- a/frontend/app/src/entities/proposed-changes/api/create-proposed-change-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/create-proposed-change-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const CREATE_PROPOSED_CHANGE = graphql(` mutation CoreProposedChangeCreate( diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-change-details-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-change-details-from-api.ts index 9cfb42004df..d2f296cb91e 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-change-details-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-change-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_PROPOSED_CHANGE_DETAILS = graphql(` query GET_PROPOSED_CHANGE_DETAILS($proposedChangeId: ID) { diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-change-thread-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-change-thread-from-api.ts index 2a2fd26431e..36eebde12e7 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-change-thread-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-change-thread-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_THREAD = graphql(` query GetCoreThread($ids: [ID]) { diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-available-actions-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-available-actions-from-api.ts index 00d0b8ea12c..3f7e53707c6 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-available-actions-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-available-actions-from-api.ts @@ -1,7 +1,10 @@ -import { graphql, type ResultOf, type VariablesOf } from "gql.tada"; - +import { + graphql, + graphqlClient, + type ResultOf, + type VariablesOf, +} from "@/shared/api/graphql/client"; import type { ActionAvailability } from "@/shared/api/graphql/generated/types"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; const QUERY = graphql(` query actions($proposedChangeId: String!) { diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-counts-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-counts-from-api.ts index 97681c0fe88..113478d3585 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-counts-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-counts-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addFiltersToRequest } from "@/shared/api/graphql/utils"; import type { Filter } from "@/entities/nodes/filters/domain/model/filter"; @@ -15,7 +14,7 @@ export interface ProposedChangesCountsFromApiParams { export const getProposedChangesCountsFromApi = async ({ filters, }: ProposedChangesCountsFromApiParams) => { - const query = gql( + const query = graphql( jsonToGraphQLQuery({ query: { __name: "GET_PROPOSED_CHANGE_COUNTS", diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts index ebb9e3b2f36..95704da5ba5 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts @@ -1,17 +1,19 @@ -import { gql } from "@apollo/client"; -import { EnumType, jsonToGraphQLQuery } from "json-to-graphql-query"; +import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, + addOrderByToRequest, addRelationshipsToRequest, } from "@/shared/api/graphql/utils"; import type { PaginationParams } from "@/shared/api/types"; import { DEFAULT_PAGE_SIZE } from "@/shared/utils/pagination"; import type { Filter } from "@/entities/nodes/filters/domain/model/filter"; +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; import { PROPOSED_CHANGE_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change"; +import { PROPOSED_CHANGE_DEFAULT_SORT } from "@/entities/proposed-changes/domain/model/proposed-change-sort"; import type { AttributeSchema, ModelSchema, @@ -21,6 +23,7 @@ import type { export interface ProposedChangesFromApiParams extends PaginationParams { schema: ModelSchema; filters?: Array; + sort?: Array; getAttributesVisible: (attributes: AttributeSchema[]) => AttributeSchema[]; getRelationshipsVisible: (relationships: RelationshipSchema[]) => RelationshipSchema[]; } @@ -30,6 +33,7 @@ export const getProposedChangesFromApi = async ({ limit = DEFAULT_PAGE_SIZE, offset, filters, + sort, getAttributesVisible, getRelationshipsVisible, }: ProposedChangesFromApiParams) => { @@ -45,9 +49,7 @@ export const getProposedChangesFromApi = async ({ __args: { limit, offset, - order: { - by: [{ field: "node_metadata__created_at", direction: new EnumType("DESC") }], - }, + ...addOrderByToRequest(sort?.length ? sort : [PROPOSED_CHANGE_DEFAULT_SORT]), ...(filters ? addFiltersToRequest(filters) : {}), }, count: true, @@ -86,7 +88,7 @@ export const getProposedChangesFromApi = async ({ }, }); - const query = gql(queryString); + const query = graphql(queryString); return graphqlClient.query({ query, }); diff --git a/frontend/app/src/entities/proposed-changes/api/update-proposed-change-review-from-api.ts b/frontend/app/src/entities/proposed-changes/api/update-proposed-change-review-from-api.ts index 4e77780064a..da0deed2a63 100644 --- a/frontend/app/src/entities/proposed-changes/api/update-proposed-change-review-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/update-proposed-change-review-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const MUTATION = graphql(` mutation ProposedChangeReview($proposedChangeId: String!, $decision: ProposedChangeApprovalDecision!) { diff --git a/frontend/app/src/entities/proposed-changes/domain/model/proposed-change-sort.ts b/frontend/app/src/entities/proposed-changes/domain/model/proposed-change-sort.ts new file mode 100644 index 00000000000..b0f4c64629c --- /dev/null +++ b/frontend/app/src/entities/proposed-changes/domain/model/proposed-change-sort.ts @@ -0,0 +1,6 @@ +import { SORT_DIRECTION, type Sort } from "@/entities/nodes/sort/domain/model/sort"; + +export const PROPOSED_CHANGE_DEFAULT_SORT: Sort = { + field: "node_metadata__created_at", + direction: SORT_DIRECTION.DESC, +}; diff --git a/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.test.ts b/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.test.ts new file mode 100644 index 00000000000..c70b8a5400e --- /dev/null +++ b/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; + +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; +import { computeProposedChangeSort } from "@/entities/proposed-changes/domain/rules/compute-proposed-change-sort"; + +describe("computeProposedChangeSort", () => { + test("falls back to newest created first when nothing is applied", () => { + // GIVEN + const appliedSort: Sort[] = []; + + // WHEN + const sort = computeProposedChangeSort(appliedSort); + + // THEN + expect(sort).toEqual([{ field: "node_metadata__created_at", direction: "DESC" }]); + }); + + test("keeps the applied sort untouched", () => { + // GIVEN + const appliedSort: Sort[] = [{ field: "node_metadata__updated_at", direction: "ASC" }]; + + // WHEN + const sort = computeProposedChangeSort(appliedSort); + + // THEN + expect(sort).toEqual(appliedSort); + }); +}); diff --git a/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.ts b/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.ts new file mode 100644 index 00000000000..24dffa2f95d --- /dev/null +++ b/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.ts @@ -0,0 +1,7 @@ +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; +import { PROPOSED_CHANGE_DEFAULT_SORT } from "@/entities/proposed-changes/domain/model/proposed-change-sort"; + +/** The order the list actually queries: what the user chose, else the list default. */ +export function computeProposedChangeSort(sort: Sort[]): Sort[] { + return sort.length > 0 ? sort : [PROPOSED_CHANGE_DEFAULT_SORT]; +} diff --git a/frontend/app/src/entities/proposed-changes/ui/proposed-change-item.tsx b/frontend/app/src/entities/proposed-changes/ui/proposed-change-item.tsx index 0a4312110de..706149a415c 100644 --- a/frontend/app/src/entities/proposed-changes/ui/proposed-change-item.tsx +++ b/frontend/app/src/entities/proposed-changes/ui/proposed-change-item.tsx @@ -1,9 +1,11 @@ import { Icon } from "@iconify-icon/react"; import { Tooltip } from "@infrahub/ui"; +import { ClockIcon } from "lucide-react"; import { ListBoxItem } from "react-aria-components"; import { Link } from "react-router"; import { constructPath } from "@/shared/api/rest/fetch"; +import { Row } from "@/shared/components/container"; import { DateDisplay } from "@/shared/components/display/date-display"; import { Badge } from "@/shared/components/ui/badge"; import { classNames } from "@/shared/utils/common"; @@ -12,6 +14,7 @@ import { CHECK_OBJECT } from "@/entities/diff/domain/model/check"; import { getNodeLabel } from "@/entities/nodes/object/domain/rules/get-node-label"; import { useObjectTableContext } from "@/entities/nodes/object/ui/object-table/object-table-context"; import { useObjectsCount } from "@/entities/nodes/object/ui/queries/get-objects-count.query"; +import { useSort } from "@/entities/nodes/sort/ui/hooks/use-sort"; import type { ProposedChangeItem } from "@/entities/proposed-changes/domain/use-cases/get-proposed-changes"; import { ProposedChangeDiffSummary } from "@/entities/proposed-changes/ui/diff-summary/proposed-change-diff-summary"; import { ProposedChangesActionCell } from "@/entities/proposed-changes/ui/proposed-changes-actions-cell"; @@ -23,9 +26,12 @@ type ProposedChangesItemProps = { }; export const ProposedChangesItem = ({ proposedChange }: ProposedChangesItemProps) => { - const { permission } = useObjectTableContext(); + const { permission, selectedSchema } = useObjectTableContext(); + const { appliedSort } = useSort(selectedSchema); const { node, metadata } = proposedChange; + const showUpdatedAt = appliedSort.some((sort) => sort.field === "node_metadata__updated_at"); + return (
@@ -37,6 +43,7 @@ export const ProposedChangesItem = ({ proposedChange }: ProposedChangesItemProps isDraft={!!node.is_draft?.value} isApproved={!!node.approved_by.edges.length} createdAt={metadata.created_at} + updatedAt={showUpdatedAt ? metadata.updated_at : null} branchName={node.source_branch?.value} /> @@ -65,6 +72,7 @@ type ProposedChangesInfoProps = { isDraft: boolean; isApproved: boolean; createdAt: string | null; + updatedAt?: string | null; branchName?: string; }; @@ -75,6 +83,7 @@ const ProposedChangesInfo = ({ isDraft, isApproved, createdAt, + updatedAt, branchName, }: ProposedChangesInfoProps) => { return ( @@ -102,13 +111,18 @@ const ProposedChangesInfo = ({ {isApproved && approved}
- + {branchName} Opened by {author} - + {updatedAt && ( + <> + Updated + + )} +
); diff --git a/frontend/app/src/entities/proposed-changes/ui/proposed-change-table-filter.tsx b/frontend/app/src/entities/proposed-changes/ui/proposed-change-table-filter.tsx deleted file mode 100644 index 4ed1f62e607..00000000000 --- a/frontend/app/src/entities/proposed-changes/ui/proposed-change-table-filter.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Icon } from "@iconify-icon/react"; -import type { PopoverTriggerProps } from "@radix-ui/react-popover"; -import { useState } from "react"; - -import { cellHeaderStyle, cellsStyle } from "@/shared/components/table/style"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover"; -import { classNames } from "@/shared/utils/common"; - -import { isFieldFiltered } from "@/entities/nodes/filters/domain/rules/is-field-filtered"; -import { useFilters } from "@/entities/nodes/filters/ui/hooks/use-filters"; -import { AttributeFilterForm } from "@/entities/nodes/object/ui/filters/attribute-filter-form"; -import { RelationshipFilterForm } from "@/entities/nodes/object/ui/filters/relationship-filter-form"; -import type { - AttributeSchema, - ModelSchema, - RelationshipSchema, -} from "@/entities/schema/domain/model/schema"; -import { isRelationshipSchema } from "@/entities/schema/domain/rules/is-relationship-schema"; -import { FieldSchemaIcon } from "@/entities/schema/ui/field-schema-icon"; - -export interface TableColumnHeaderProps extends PopoverTriggerProps { - schema: ModelSchema; - columnSchema: AttributeSchema | RelationshipSchema; - customLabel?: string; -} - -export function ProposedChangeTableFilter({ - schema, - columnSchema, - customLabel, - ...props -}: TableColumnHeaderProps) { - const [filters] = useFilters(); - const [showFilters, setShowFilters] = useState(false); - const currentColumnFilters = filters.find((f) => isFieldFiltered(f, columnSchema.name)); - - const closePopover = () => { - setShowFilters(false); - }; - - return ( - - - - - - {customLabel ?? columnSchema.label ?? columnSchema.name} - - - - - -
- Filter by {columnSchema.label ?? columnSchema.name} -
- {isRelationshipSchema(columnSchema) ? ( - - ) : ( - - )} -
-
- ); -} diff --git a/frontend/app/src/entities/proposed-changes/ui/proposed-changes-manager-toolbar.tsx b/frontend/app/src/entities/proposed-changes/ui/proposed-changes-manager-toolbar.tsx index 089fe95d3f8..3c0f29037fc 100644 --- a/frontend/app/src/entities/proposed-changes/ui/proposed-changes-manager-toolbar.tsx +++ b/frontend/app/src/entities/proposed-changes/ui/proposed-changes-manager-toolbar.tsx @@ -8,6 +8,7 @@ import { Row } from "@/shared/components/container"; import { ActiveObjectFilterTags } from "@/entities/nodes/object/ui/filters/active-object-filter-tags"; import { FilterSearchInput } from "@/entities/nodes/object/ui/filters/filter-search-input"; import { ObjectItemsHeader } from "@/entities/nodes/object/ui/object-items-header"; +import { SortPicker } from "@/entities/nodes/sort/ui/sort-picker"; import type { Permission } from "@/entities/permission/domain/model/permission"; import type { ModelSchema } from "@/entities/schema/domain/model/schema"; @@ -29,6 +30,8 @@ export function ProposedChangesManagerToolbar({ + + { - return relationship.name === "created_by"; - }); - const reviewersRelationship = schema.relationships?.find((relationship) => { return relationship.name === "reviewers"; }); @@ -66,31 +62,43 @@ export function ProposedChangesTableFilters({ schema }: ProposedChangesTableHead
{draftAttribute && ( - + )} {stateAttribute && ( - + )} {sourceBranchAttribute && ( - - )} - - {authorRelationship && ( - )} {reviewersRelationship && ( - + )} {approversRelationship && ( - + )}
diff --git a/frontend/app/src/entities/proposed-changes/ui/proposed-changes-table.tsx b/frontend/app/src/entities/proposed-changes/ui/proposed-changes-table.tsx index e0012eb4be5..36449424e0b 100644 --- a/frontend/app/src/entities/proposed-changes/ui/proposed-changes-table.tsx +++ b/frontend/app/src/entities/proposed-changes/ui/proposed-changes-table.tsx @@ -8,7 +8,9 @@ import { classNames } from "@/shared/utils/common"; import { useFilters } from "@/entities/nodes/filters/ui/hooks/use-filters"; import { ObjectTableEmpty } from "@/entities/nodes/object/ui/object-table/object-table-empty"; +import { useSort } from "@/entities/nodes/sort/ui/hooks/use-sort"; import { computeProposedChangeFilters } from "@/entities/proposed-changes/domain/rules/compute-proposed-change-filters"; +import { computeProposedChangeSort } from "@/entities/proposed-changes/domain/rules/compute-proposed-change-sort"; import { ProposedChangesItem } from "@/entities/proposed-changes/ui/proposed-change-item"; import { ProposedChangesTableFilters } from "@/entities/proposed-changes/ui/proposed-changes-table-filters"; import { ProposedChangesTableSkeleton } from "@/entities/proposed-changes/ui/proposed-changes-table-skeleton"; @@ -24,11 +26,13 @@ export function ProposedChangesTable({ schema, className }: ProposedChangesTable const [proposedChangeState] = useQueryState(QSP.PROPOSED_CHANGES_STATE); const [filters] = useFilters(); + const { appliedSort } = useSort(schema); const { data, fetchNextPage, hasNextPage, isPending, isFetchingNextPage } = useGetProposedChanges( { schema, filters: computeProposedChangeFilters({ filters, qsp: proposedChangeState as string }), + sort: computeProposedChangeSort(appliedSort), } ); diff --git a/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.test.ts b/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.test.ts index 08391c4f0ad..7d28315d572 100644 --- a/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.test.ts +++ b/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Filter } from "@/entities/nodes/filters/domain/model/filter"; +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; import { PROPOSED_CHANGE_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change"; import { PROPOSED_CHANGES_THREAD_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change-thread"; @@ -18,15 +19,35 @@ describe("proposedChangesQueryKeys", () => { it("returns query key for list", () => { // GIVEN const filters: Filter[] = [{ name: "status__value", value: "open" }]; + const sort: Sort[] = [{ field: "node_metadata__created_at", direction: "DESC" }]; const params = { filters, + sort, }; // WHEN const result = proposedChangesQueryKeys.list(params); // THEN - expect(result).toEqual(["objects", PROPOSED_CHANGE_OBJECT, filters]); + expect(result).toEqual(["objects", PROPOSED_CHANGE_OBJECT, filters, sort]); + }); + + it("returns a different list query key per sort", () => { + // GIVEN + const filters: Filter[] = [{ name: "status__value", value: "open" }]; + + // WHEN + const newestFirst = proposedChangesQueryKeys.list({ + filters, + sort: [{ field: "node_metadata__created_at", direction: "DESC" }], + }); + const oldestFirst = proposedChangesQueryKeys.list({ + filters, + sort: [{ field: "node_metadata__created_at", direction: "ASC" }], + }); + + // THEN + expect(newestFirst).not.toEqual(oldestFirst); }); it("returns query key for count", () => { diff --git a/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.ts b/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.ts index 31b975cd315..f6d07917822 100644 --- a/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.ts +++ b/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.ts @@ -1,16 +1,18 @@ import type { Filter } from "@/entities/nodes/filters/domain/model/filter"; import { objectQueryKeys } from "@/entities/nodes/object/ui/queries/object.query-keys"; +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; import { PROPOSED_CHANGE_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change"; import { PROPOSED_CHANGES_THREAD_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change-thread"; export interface ProposedChangesListKeysParams { filters?: Filter[]; + sort?: Sort[]; } export const proposedChangesQueryKeys = { all: [...objectQueryKeys.all, PROPOSED_CHANGE_OBJECT] as const, - list: ({ filters }: ProposedChangesListKeysParams) => - [...proposedChangesQueryKeys.all, filters] as const, + list: ({ filters, sort }: ProposedChangesListKeysParams) => + [...proposedChangesQueryKeys.all, filters, sort] as const, count: ({ filters }: ProposedChangesListKeysParams) => [...proposedChangesQueryKeys.all, "count", filters] as const, detail: (proposedChangeId: string) => diff --git a/frontend/app/src/entities/repository/api/check-connectivity-from-api.ts b/frontend/app/src/entities/repository/api/check-connectivity-from-api.ts index 431ddbc0bae..9a3494bcd76 100644 --- a/frontend/app/src/entities/repository/api/check-connectivity-from-api.ts +++ b/frontend/app/src/entities/repository/api/check-connectivity-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const CHECK_REPOSITORY_CONNECTIVITY = graphql(` mutation CHECK_REPOSITORY_CONNECTIVITY($repositoryId: String!) { diff --git a/frontend/app/src/entities/repository/api/get-repository-group-from-api.ts b/frontend/app/src/entities/repository/api/get-repository-group-from-api.ts index 28aa9d93898..420abdfa948 100644 --- a/frontend/app/src/entities/repository/api/get-repository-group-from-api.ts +++ b/frontend/app/src/entities/repository/api/get-repository-group-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const REPOSITORY_GROUP = graphql(` diff --git a/frontend/app/src/entities/repository/api/import-current-commit-from-api.ts b/frontend/app/src/entities/repository/api/import-current-commit-from-api.ts index 7c5b4772f08..ff0ebeff201 100644 --- a/frontend/app/src/entities/repository/api/import-current-commit-from-api.ts +++ b/frontend/app/src/entities/repository/api/import-current-commit-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const IMPORT_CURRENT_COMMIT = graphql(` diff --git a/frontend/app/src/entities/repository/api/reimport-last-commit-from-api.ts b/frontend/app/src/entities/repository/api/reimport-last-commit-from-api.ts index 4ceec003ba0..0af40b254c6 100644 --- a/frontend/app/src/entities/repository/api/reimport-last-commit-from-api.ts +++ b/frontend/app/src/entities/repository/api/reimport-last-commit-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const REIMPORT_LAST_COMMIT = graphql(` diff --git a/frontend/app/src/entities/resource-manager/api/allocate-resource-from-api.ts b/frontend/app/src/entities/resource-manager/api/allocate-resource-from-api.ts index 52b484ff932..10d01e239de 100644 --- a/frontend/app/src/entities/resource-manager/api/allocate-resource-from-api.ts +++ b/frontend/app/src/entities/resource-manager/api/allocate-resource-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; export interface AllocateResourceFromApiParams extends BranchContextParams { @@ -35,7 +34,7 @@ export function allocateResourceFromApi({ }); return graphqlClient.mutate({ - mutation: gql(mutation), + mutation: graphql(mutation), context: { branch: branchName, }, diff --git a/frontend/app/src/entities/resource-manager/api/get-number-pools-from-api.ts b/frontend/app/src/entities/resource-manager/api/get-number-pools-from-api.ts index ca2f4eafd01..c75e140fae9 100644 --- a/frontend/app/src/entities/resource-manager/api/get-number-pools-from-api.ts +++ b/frontend/app/src/entities/resource-manager/api/get-number-pools-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const GET_NUMBER_POOLS = graphql(` diff --git a/frontend/app/src/entities/resource-manager/api/get-pool-utilization-from-api.ts b/frontend/app/src/entities/resource-manager/api/get-pool-utilization-from-api.ts index e508b500f55..afa834312ca 100644 --- a/frontend/app/src/entities/resource-manager/api/get-pool-utilization-from-api.ts +++ b/frontend/app/src/entities/resource-manager/api/get-pool-utilization-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_POOL_UTILIZATION = graphql(` query GET_POOL_UTILIZATION($poolId: String!) { diff --git a/frontend/app/src/entities/resource-manager/api/get-resource-allocated-from-api.ts b/frontend/app/src/entities/resource-manager/api/get-resource-allocated-from-api.ts index a844915e62e..97dc2dd37cd 100644 --- a/frontend/app/src/entities/resource-manager/api/get-resource-allocated-from-api.ts +++ b/frontend/app/src/entities/resource-manager/api/get-resource-allocated-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_RESOURCE_ALLOCATED = graphql(` query GET_RESOURCE_POOL_ALLOCATED( diff --git a/frontend/app/src/entities/schema/api/add-dropdown-from-api.ts b/frontend/app/src/entities/schema/api/add-dropdown-from-api.ts index ed40363901d..5b397889f49 100644 --- a/frontend/app/src/entities/schema/api/add-dropdown-from-api.ts +++ b/frontend/app/src/entities/schema/api/add-dropdown-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const DROPDOWN_ADD_MUTATION = graphql(` mutation DropdownAdd( diff --git a/frontend/app/src/entities/schema/api/add-enum-from-api.ts b/frontend/app/src/entities/schema/api/add-enum-from-api.ts index c0136b22301..bafe9f9a22f 100644 --- a/frontend/app/src/entities/schema/api/add-enum-from-api.ts +++ b/frontend/app/src/entities/schema/api/add-enum-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const ENUM_ADD_MUTATION = graphql(` mutation EnumAdd($kind: String!, $attribute: String!, $enum: String!) { diff --git a/frontend/app/src/entities/schema/api/remove-dropdown-from-api.ts b/frontend/app/src/entities/schema/api/remove-dropdown-from-api.ts index 478502bfbb4..6f234776c97 100644 --- a/frontend/app/src/entities/schema/api/remove-dropdown-from-api.ts +++ b/frontend/app/src/entities/schema/api/remove-dropdown-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const DROPDOWN_REMOVE_MUTATION = graphql(` mutation DropdownDelete($kind: String!, $attribute: String!, $dropdown: String!) { diff --git a/frontend/app/src/entities/schema/api/remove-enum-from-api.ts b/frontend/app/src/entities/schema/api/remove-enum-from-api.ts index 44a57911e1e..5b57bd243d2 100644 --- a/frontend/app/src/entities/schema/api/remove-enum-from-api.ts +++ b/frontend/app/src/entities/schema/api/remove-enum-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const ENUM_REMOVE_MUTATION = graphql(` mutation EnumDelete($kind: String!, $attribute: String!, $enum: String!) { diff --git a/frontend/app/src/entities/schema/domain/model/attribute-kind.ts b/frontend/app/src/entities/schema/domain/model/attribute-kind.ts index d085c1a4c8e..a7ecad8cd51 100644 --- a/frontend/app/src/entities/schema/domain/model/attribute-kind.ts +++ b/frontend/app/src/entities/schema/domain/model/attribute-kind.ts @@ -17,6 +17,7 @@ export const ATTRIBUTE_KIND = { BANDWIDTH: "Bandwidth", IP_HOST: "IPHost", IP_NETWORK: "IPNetwork", + IP_ADDRESS: "IPAddress", CHECKBOX: "Checkbox", LIST: "List", JSON: "JSON", @@ -39,5 +40,6 @@ export const ATTRIBUTE_KINDS_FOR_LIST_VIEW: readonly AttributeKind[] = [ ATTRIBUTE_KIND.BANDWIDTH, ATTRIBUTE_KIND.IP_HOST, ATTRIBUTE_KIND.IP_NETWORK, + ATTRIBUTE_KIND.IP_ADDRESS, ATTRIBUTE_KIND.DATETIME, ]; diff --git a/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.test.ts b/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.test.ts new file mode 100644 index 00000000000..119e99874a9 --- /dev/null +++ b/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { validateIpAddressAttribute } from "./validate-ip-address-attribute"; + +describe("validateIpAddressAttribute", () => { + it.each([ + "10.0.0.1", + "0.0.0.0", + "255.255.255.255", + "2001:db8::1", + "::1", + "::", + "::ffff:10.0.0.1", + "1:2:3:4:5:6:7:8", + // a trailing dotted quad carries the last two groups, so this is a full address + "1:2:3:4:5:6:1.2.3.4", + "::1.2.3.4", + // "::" may stand for a single group of zeros + "1:2:3:4:5:6:7::", + ])("accepts the bare address %s", (value) => { + expect(validateIpAddressAttribute({}, value)).toEqual({ success: true, data: value }); + }); + + it.each([ + "10.0.0.1/32", + "10.0.0.1/24", + "10.0.0.0/255.255.255.0", + "2001:db8::1/128", + ])("rejects %s for carrying a prefix", (value) => { + expect(validateIpAddressAttribute({}, value)).toEqual({ + success: false, + error: "Must be a bare IP address, without a prefix or netmask", + }); + }); + + it.each([ + "010.0.0.1", + "10.0.0.256", + "10.0.1", + "not-an-ip", + "2001:db8::1::2", + "12345::1", + "1:2:3:4:5:6:7:8:9", + // a trailing dotted quad counts as two groups, so these overflow + "1:2:3:4:5:6:7:1.2.3.4", + "1.2.3.4::1", + // "::" has to stand for at least one group, leaving no room after eight + "::1:2:3:4:5:6:7:8", + "1:2:3:4:5:6:7:8::", + ])("rejects %s as malformed", (value) => { + expect(validateIpAddressAttribute({}, value)).toEqual({ + success: false, + error: "Must be a valid IPv4 or IPv6 address", + }); + }); + + it("treats an empty value as valid when the attribute is optional", () => { + expect(validateIpAddressAttribute({}, "")).toEqual({ success: true, data: "" }); + expect(validateIpAddressAttribute({}, null)).toEqual({ success: true, data: "" }); + }); + + it("requires a value when the attribute is mandatory", () => { + expect(validateIpAddressAttribute({ isRequired: true }, "")).toEqual({ + success: false, + error: "Required", + }); + }); +}); diff --git a/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.ts b/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.ts new file mode 100644 index 00000000000..1718ce5a5f7 --- /dev/null +++ b/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.ts @@ -0,0 +1,62 @@ +const IPV4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + +function isValidIpv4(value: string): boolean { + const match = IPV4_PATTERN.exec(value); + if (!match) return false; + + return match.slice(1).every((octet) => { + // a leading zero is ambiguous between decimal and octal, so the backend rejects it too + if (octet.length > 1 && octet.startsWith("0")) return false; + return Number(octet) <= 255; + }); +} + +const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/; + +function isValidIpv6(value: string): boolean { + // At most one "::" run, and it is what allows fewer than the full eight groups. + const runs = value.split("::"); + if (runs.length > 2) return false; + const isCompressed = runs.length === 2; + + const head = runs[0] ?? ""; + const tail = runs[1] ?? ""; + const headParts = head === "" ? [] : head.split(":"); + const tailParts = tail === "" ? [] : tail.split(":"); + const parts = [...headParts, ...tailParts]; + + const last = parts.at(-1); + const endsWithDottedQuad = last !== undefined && !HEX_GROUP.test(last); + if (endsWithDottedQuad && !isValidIpv4(last)) return false; + + const hexParts = endsWithDottedQuad ? parts.slice(0, -1) : parts; + if (hexParts.some((part) => !HEX_GROUP.test(part))) return false; + + // A trailing dotted quad carries 32 bits, so it stands for the final two groups. + const groupCount = parts.length + (endsWithDottedQuad ? 1 : 0); + + // "::" stands for at least one group of zeros, so the explicit groups must leave room for it. + return isCompressed ? groupCount <= 7 : groupCount === 8; +} + +export function validateIpAddressAttribute( + { isRequired = false }: { isRequired?: boolean }, + value: string | null | undefined +): { success: true; data: string } | { success: false; error: string } { + if (!value) { + return isRequired ? { success: false, error: "Required" } : { success: true, data: "" }; + } + + if (value.includes("/")) { + return { + success: false, + error: "Must be a bare IP address, without a prefix or netmask", + }; + } + + if (!isValidIpv4(value) && !isValidIpv6(value)) { + return { success: false, error: "Must be a valid IPv4 or IPv6 address" }; + } + + return { success: true, data: value }; +} diff --git a/frontend/app/src/entities/schema/ui/field-schema-icon.tsx b/frontend/app/src/entities/schema/ui/field-schema-icon.tsx index cb542a6333b..052b1682982 100644 --- a/frontend/app/src/entities/schema/ui/field-schema-icon.tsx +++ b/frontend/app/src/entities/schema/ui/field-schema-icon.tsx @@ -26,6 +26,7 @@ export const ATTRIBUTE_ICONS: Record = { Bandwidth: "mdi:gauge", IPHost: "mdi:ip-network-outline", IPNetwork: "mdi:ip-network-outline", + IPAddress: "mdi:ip-outline", Checkbox: "mdi:checkbox-marked-circle-outline", List: "mdi:format-list-bulleted-square", JSON: "mdi:code-json", diff --git a/frontend/app/src/entities/tasks/api/cancel-task-from-api.ts b/frontend/app/src/entities/tasks/api/cancel-task-from-api.ts index 16de559f811..61f59e32b38 100644 --- a/frontend/app/src/entities/tasks/api/cancel-task-from-api.ts +++ b/frontend/app/src/entities/tasks/api/cancel-task-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const CANCEL_TASK = graphql(` mutation CANCEL_TASK($id: String!) { diff --git a/frontend/app/src/entities/tasks/api/check-task-details-from-api.ts b/frontend/app/src/entities/tasks/api/check-task-details-from-api.ts index 1a0001517d3..76da46fd17b 100644 --- a/frontend/app/src/entities/tasks/api/check-task-details-from-api.ts +++ b/frontend/app/src/entities/tasks/api/check-task-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const TASK_DETAILS_CHECK = graphql(` query TASK_DETAILS_CHECK( diff --git a/frontend/app/src/entities/tasks/api/get-branch-task-status-from-api.ts b/frontend/app/src/entities/tasks/api/get-branch-task-status-from-api.ts index 7a5461815f2..89d75662d92 100644 --- a/frontend/app/src/entities/tasks/api/get-branch-task-status-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-branch-task-status-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const TASKS_BRANCH_STATUS_COUNT = graphql(` query TASKS_BRANCH_STATUS_COUNT($branch: String!) { diff --git a/frontend/app/src/entities/tasks/api/get-task-count-from-api.ts b/frontend/app/src/entities/tasks/api/get-task-count-from-api.ts index 4fb79761e56..6f5fe39e18a 100644 --- a/frontend/app/src/entities/tasks/api/get-task-count-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-task-count-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const TASK_COUNT = graphql(` query TASK_COUNT( diff --git a/frontend/app/src/entities/tasks/api/get-task-details-from-api.ts b/frontend/app/src/entities/tasks/api/get-task-details-from-api.ts index e9ae71d58cb..90f4aee2192 100644 --- a/frontend/app/src/entities/tasks/api/get-task-details-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-task-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_TASK_DETAILS = graphql(` query GET_TASK_DETAILS( diff --git a/frontend/app/src/entities/tasks/api/get-task-details-title-from-api.ts b/frontend/app/src/entities/tasks/api/get-task-details-title-from-api.ts index 5a1c098fec5..32e2358dafc 100644 --- a/frontend/app/src/entities/tasks/api/get-task-details-title-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-task-details-title-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_TASK_DETAILS_TITLE = graphql(` query GET_TASK_DETAILS_TITLE_QUERY($ids: [String!]) { diff --git a/frontend/app/src/entities/tasks/api/get-task-list-from-api.ts b/frontend/app/src/entities/tasks/api/get-task-list-from-api.ts index 00b36a4b411..b8bb3e5530d 100644 --- a/frontend/app/src/entities/tasks/api/get-task-list-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-task-list-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const GET_TASK_LIST = graphql(` query GET_TASK_LIST( diff --git a/frontend/app/src/entities/tasks/api/get-tasks-homepage-from-api.ts b/frontend/app/src/entities/tasks/api/get-tasks-homepage-from-api.ts index 77d49423bc1..480d0d96b86 100644 --- a/frontend/app/src/entities/tasks/api/get-tasks-homepage-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-tasks-homepage-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_TASKS_HOMEPAGE = graphql(` query GET_TASKS_HOMEPAGE($limit: Int, $branchName: String!, $states: [StateType]) { diff --git a/frontend/app/src/entities/tasks/api/retry-task-from-api.ts b/frontend/app/src/entities/tasks/api/retry-task-from-api.ts index 365a0ff35b3..f7b3a17b881 100644 --- a/frontend/app/src/entities/tasks/api/retry-task-from-api.ts +++ b/frontend/app/src/entities/tasks/api/retry-task-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const RETRY_TASK = graphql(` mutation RETRY_TASK($id: String!) { diff --git a/frontend/app/src/entities/user-profile/api/create-account-token-from-api.ts b/frontend/app/src/entities/user-profile/api/create-account-token-from-api.ts index bda73250f79..fddb2e72e15 100644 --- a/frontend/app/src/entities/user-profile/api/create-account-token-from-api.ts +++ b/frontend/app/src/entities/user-profile/api/create-account-token-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const CREATE_ACCOUNT_TOKEN = graphql(` mutation InfrahubAccountTokenCreate($tokenName: String!, $tokenExpirationDate: String) { diff --git a/frontend/app/src/entities/user-profile/api/get-account-profile-from-api.ts b/frontend/app/src/entities/user-profile/api/get-account-profile-from-api.ts index 339549ff3c1..c9d62a18c54 100644 --- a/frontend/app/src/entities/user-profile/api/get-account-profile-from-api.ts +++ b/frontend/app/src/entities/user-profile/api/get-account-profile-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_ACCOUNT_PROFILE = graphql(` query GetAccountProfile { diff --git a/frontend/app/src/entities/user-profile/api/get-account-token-from-api.ts b/frontend/app/src/entities/user-profile/api/get-account-token-from-api.ts index 6b785e70007..6a7e2db85f0 100644 --- a/frontend/app/src/entities/user-profile/api/get-account-token-from-api.ts +++ b/frontend/app/src/entities/user-profile/api/get-account-token-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const query = graphql(` query InfrahubAccountToken { diff --git a/frontend/app/src/entities/user-profile/api/update-account-password-from-api.ts b/frontend/app/src/entities/user-profile/api/update-account-password-from-api.ts index 310e7db6efa..6fcfb12b05f 100644 --- a/frontend/app/src/entities/user-profile/api/update-account-password-from-api.ts +++ b/frontend/app/src/entities/user-profile/api/update-account-password-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const UPDATE_ACCOUNT_PASSWORD = graphql(` mutation UPDATE_ACCOUNT_PASSWORD($password: String!) { diff --git a/frontend/app/src/shared/api/graphql/client.dedup.test.ts b/frontend/app/src/shared/api/graphql/client.dedup.test.ts new file mode 100644 index 00000000000..46fe1f83781 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/client.dedup.test.ts @@ -0,0 +1,77 @@ +import { gql } from "@urql/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { graphqlClient } from "./client"; + +// Guards against urql merging concurrent identical operations that target different endpoints. +describe("concurrent identical query across endpoints", () => { + let fetchSpy: ReturnType; + + const SAME_QUERY = gql` + query SameQuery { + __typename + } + `; + + beforeEach(() => { + // Echo the endpoint back so each caller can be matched to the URL it targeted. + fetchSpy = vi.fn((url: string) => { + const [branch, at] = url.split("/graphql/")[1]?.split("?at=") ?? ["?"]; + return Promise.resolve( + new Response(JSON.stringify({ data: { __typename: at ? `${branch}@${at}` : branch } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + }); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("fires a distinct request per branch for concurrent identical query+variables", async () => { + // WHEN + const [a, b] = await Promise.all([ + graphqlClient.query({ query: SAME_QUERY, context: { branch: "branch-a" } }), + graphqlClient.query({ query: SAME_QUERY, context: { branch: "branch-b" } }), + ]); + + // THEN + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(a.data).toEqual({ __typename: "branch-a" }); + expect(b.data).toEqual({ __typename: "branch-b" }); + }); + + it("fires a distinct request per point-in-time on the same branch", async () => { + // GIVEN + const early = new Date("2026-01-01T00:00:00.000Z"); + const late = new Date("2026-06-01T00:00:00.000Z"); + + // WHEN + const [a, b] = await Promise.all([ + graphqlClient.query({ query: SAME_QUERY, context: { branch: "main", date: early } }), + graphqlClient.query({ query: SAME_QUERY, context: { branch: "main", date: late } }), + ]); + + // THEN + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(a.data).toEqual({ __typename: `main@${early.toISOString()}` }); + expect(b.data).toEqual({ __typename: `main@${late.toISOString()}` }); + }); + + it("still merges two identical concurrent queries on the same endpoint", async () => { + // WHEN + const [a, b] = await Promise.all([ + graphqlClient.query({ query: SAME_QUERY, context: { branch: "branch-a" } }), + graphqlClient.query({ query: SAME_QUERY, context: { branch: "branch-a" } }), + ]); + + // THEN + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(a.data).toEqual({ __typename: "branch-a" }); + expect(b.data).toEqual({ __typename: "branch-a" }); + }); +}); diff --git a/frontend/app/src/shared/api/graphql/client.test.ts b/frontend/app/src/shared/api/graphql/client.test.ts new file mode 100644 index 00000000000..d9425056598 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/client.test.ts @@ -0,0 +1,379 @@ +import { CombinedError, gql } from "@urql/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ERROR_CODES } from "@/shared/api/errors"; +import { PRIORITY_HEADER } from "@/shared/api/priority"; +import { queryClient } from "@/shared/api/rest/client"; +import { CONFIG } from "@/shared/config/config"; + +import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "@/entities/authentication/api/token-storage"; +import { __navigation } from "@/entities/authentication/domain/use-cases/redirect-to-login"; + +import { graphqlClient } from "./client"; +import { handleGraphQLErrors } from "./error-handling"; + +function combinedError(code: string, message = "boom") { + return new CombinedError({ + graphQLErrors: [{ message, extensions: { code, http_status: 401, data: {} } }], + }); +} + +describe("graphqlClient — endpoint targeting", () => { + let fetchSpy: ReturnType; + + const PING = gql` + query Ping { + __typename + } + `; + + beforeEach(() => { + fetchSpy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ data: { __typename: "Query" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + ); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("targets the default branch when the operation declares no context", async () => { + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + expect(fetchSpy.mock.calls[0]?.[0]).toBe(CONFIG.GRAPHQL_URL()); + }); + + it("targets the branch and point in time the operation declares", async () => { + // GIVEN + const date = new Date("2026-01-01T00:00:00.000Z"); + + // WHEN + await graphqlClient.query({ query: PING, context: { branch: "feature", date } }); + + // THEN + expect(fetchSpy.mock.calls[0]?.[0]).toBe(CONFIG.GRAPHQL_URL("feature", date)); + }); + + it("stamps X-Priority: high on every operation", async () => { + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get(PRIORITY_HEADER)).toBe("high"); + }); +}); + +describe("handleGraphQLErrors — catalogue routing", () => { + let assignSpy: ReturnType; + let originalAssign: typeof __navigation.assign; + + beforeEach(() => { + originalAssign = __navigation.assign; + assignSpy = vi.fn(); + __navigation.assign = assignSpy as unknown as typeof __navigation.assign; + }); + + afterEach(() => { + __navigation.assign = originalAssign; + localStorage.clear(); + }); + + it("redirects to /login on a persistent TOKEN_EXPIRED", () => { + // WHEN + handleGraphQLErrors(combinedError(ERROR_CODES.TOKEN_EXPIRED)); + + // THEN + expect(assignSpy).toHaveBeenCalledOnce(); + }); + + it("redirects to /login on AUTHENTICATION_REQUIRED", () => { + // WHEN + handleGraphQLErrors(combinedError(ERROR_CODES.AUTHENTICATION_REQUIRED)); + + // THEN + expect(assignSpy).toHaveBeenCalledOnce(); + }); + + it("stays silent on PERMISSION_DENIED (no redirect, no override call)", () => { + // GIVEN + const processErrorMessage = vi.fn(); + + // WHEN + handleGraphQLErrors(combinedError(ERROR_CODES.PERMISSION_DENIED), { processErrorMessage }); + + // THEN + expect(assignSpy).not.toHaveBeenCalled(); + expect(processErrorMessage).not.toHaveBeenCalled(); + }); + + it("routes a generic error through the caller's processErrorMessage override", () => { + // GIVEN + const processErrorMessage = vi.fn(); + + // WHEN + handleGraphQLErrors(combinedError(ERROR_CODES.UNDEFINED_ERROR, "nope"), { + processErrorMessage, + }); + + // THEN + expect(processErrorMessage).toHaveBeenCalledWith("nope"); + expect(assignSpy).not.toHaveBeenCalled(); + }); + + it("still redirects when an unrouted error precedes AUTHENTICATION_REQUIRED", () => { + // GIVEN + const processErrorMessage = vi.fn(); + const error = new CombinedError({ + graphQLErrors: [ + { + message: "boom", + extensions: { code: ERROR_CODES.NODE_NOT_FOUND, http_status: 404, data: {} }, + }, + { message: "gap", extensions: { code: "NOT_IN_CATALOGUE", http_status: 500, data: {} } }, + { + message: "auth", + extensions: { code: ERROR_CODES.AUTHENTICATION_REQUIRED, http_status: 401, data: {} }, + }, + ], + }); + + // WHEN + handleGraphQLErrors(error, { processErrorMessage }); + + // THEN + expect(processErrorMessage).toHaveBeenCalledWith("boom"); + expect(assignSpy).toHaveBeenCalledOnce(); + }); + + it("does nothing when there is no error", () => { + // GIVEN + const processErrorMessage = vi.fn(); + + // WHEN + handleGraphQLErrors(undefined, { processErrorMessage }); + + // THEN + expect(processErrorMessage).not.toHaveBeenCalled(); + expect(assignSpy).not.toHaveBeenCalled(); + }); +}); + +describe("graphqlClient — token refresh integration", () => { + let assignSpy: ReturnType; + let originalAssign: typeof __navigation.assign; + let fetchQuerySpy: ReturnType; + let fetchSpy: ReturnType; + + const PING = gql` + query Ping { + __typename + } + `; + + const PING_MUTATION = gql` + mutation Ping { + __typename + } + `; + + function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const tokenExpiredBody = { + data: null, + errors: [ + { + message: "Token expired", + extensions: { code: ERROR_CODES.TOKEN_EXPIRED, http_status: 401, data: {} }, + }, + ], + }; + + beforeEach(() => { + localStorage.setItem(ACCESS_TOKEN_KEY, "old-token"); + localStorage.setItem(REFRESH_TOKEN_KEY, "old-refresh"); + originalAssign = __navigation.assign; + assignSpy = vi.fn(); + __navigation.assign = assignSpy as unknown as typeof __navigation.assign; + fetchQuerySpy = vi.spyOn(queryClient, "fetchQuery"); + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + __navigation.assign = originalAssign; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it("sends queries as POST (backend /graphql rejects GET → SPA fallback HTML)", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce(jsonResponse({ data: { __typename: "Query" } })); + + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined; + expect(init?.method).toBe("POST"); + }); + + it("injects __typename into selections (Apollo InMemoryCache parity)", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce(jsonResponse({ data: {} })); + + // WHEN + await graphqlClient.query({ + query: gql` + { + InfraDevice { + edges { + node { + id + } + } + } + } + `, + }); + + // THEN + const body = JSON.parse((fetchSpy.mock.calls[0]?.[1] as RequestInit).body as string); + expect(body.query).toContain("__typename"); + }); + + it("attaches the bearer token when an access token is present", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce(jsonResponse({ data: {} })); + + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get("authorization")).toBe("Bearer old-token"); + }); + + it("omits the bearer token when no access token is present", async () => { + // GIVEN + localStorage.removeItem(ACCESS_TOKEN_KEY); + fetchSpy.mockResolvedValueOnce(jsonResponse({ data: {} })); + + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get("authorization")).toBeNull(); + }); + + it("bails to /login when the refresh throws", async () => { + // GIVEN + fetchSpy.mockImplementation(() => Promise.resolve(jsonResponse(tokenExpiredBody))); + fetchQuerySpy.mockRejectedValue(new Error("refresh failed")); + + // WHEN + await graphqlClient.query({ query: PING }).catch(() => {}); + + // THEN + expect(assignSpy).toHaveBeenCalled(); + }); + + it("refreshes once and replays successfully on TOKEN_EXPIRED", async () => { + // GIVEN + fetchSpy + .mockResolvedValueOnce(jsonResponse(tokenExpiredBody)) + .mockResolvedValueOnce(jsonResponse({ data: { __typename: "Query" } })); + fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); + + // WHEN + const result = await graphqlClient.query({ query: PING }); + + // THEN + expect(result.data).toEqual({ __typename: "Query" }); + expect(result.errors).toBeUndefined(); + expect(fetchQuerySpy).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(assignSpy).not.toHaveBeenCalled(); + const replay = new Headers((fetchSpy.mock.calls[1]?.[1] as RequestInit).headers); + expect(replay.get(PRIORITY_HEADER)).toBe("high"); + }); + + it("bails to /login when TOKEN_EXPIRED persists after the single replay", async () => { + // GIVEN + fetchSpy.mockImplementation(() => Promise.resolve(jsonResponse(tokenExpiredBody))); + fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); + + // WHEN + const querying = graphqlClient.query({ query: PING }); + + // THEN + await expect(querying).rejects.toThrow("Token expired"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchQuerySpy).toHaveBeenCalledOnce(); + expect(assignSpy).toHaveBeenCalled(); + }); + + it("rejects a query on GraphQL errors even when the response carries data", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + data: { __typename: "Query" }, + errors: [ + { + message: "partial", + extensions: { code: ERROR_CODES.UNDEFINED_ERROR, http_status: 500, data: {} }, + }, + ], + }) + ); + + // WHEN + const querying = graphqlClient.query({ + query: PING, + context: { processErrorMessage: () => {} }, + }); + + // THEN + await expect(querying).rejects.toThrow("partial"); + }); + + it("rejects when a mutation responds with GraphQL errors", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + data: null, + errors: [ + { + message: "Cannot delete Device 'x'.", + extensions: { code: ERROR_CODES.UNDEFINED_ERROR, http_status: 500, data: {} }, + }, + ], + }) + ); + + // WHEN + const mutating = graphqlClient.mutate({ + mutation: PING_MUTATION, + context: { processErrorMessage: () => {} }, + }); + + // THEN + await expect(mutating).rejects.toThrow("Cannot delete Device 'x'."); + }); +}); diff --git a/frontend/app/src/shared/api/graphql/client.ts b/frontend/app/src/shared/api/graphql/client.ts new file mode 100644 index 00000000000..90a1d7eaa34 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/client.ts @@ -0,0 +1,134 @@ +import { + type AnyVariables, + Client, + type CombinedError, + type DocumentInput, + fetchExchange, + formatDocument, + makeOperation, + mapExchange, +} from "@urql/core"; +import { authExchange } from "@urql/exchange-auth"; + +import { ERROR_CODES } from "@/shared/api/errors"; +import { handleGraphQLErrors, hasCatalogueCode } from "@/shared/api/graphql/error-handling"; +import type { GraphQLRequestContext, GraphQLResult } from "@/shared/api/graphql/types"; +import { DEFAULT_PRIORITY, PRIORITY_HEADER } from "@/shared/api/priority"; +import { queryClient } from "@/shared/api/rest/client"; +import { CONFIG } from "@/shared/config/config"; + +import { getAccessToken } from "@/entities/authentication/api/token-storage"; +import { redirectToLogin } from "@/entities/authentication/domain/use-cases/redirect-to-login"; +import { refreshAccessTokenQueryOptions } from "@/entities/authentication/ui/queries/refresh-access-token.query"; + +// biome-ignore lint/performance/noBarrelFile: Re-exported so authoring and running a query need only this module. +export { graphql, type ResultOf, type VariablesOf } from "gql.tada"; + +// Add `__typename` to every selection set +const addTypenameExchange = mapExchange({ + onOperation: (operation) => + makeOperation( + operation.kind, + { ...operation, query: formatDocument(operation.query) }, + operation.context + ), +}); + +const authenticationExchange = authExchange(async (authUtilities) => { + return { + addAuthToOperation: (operation) => { + const accessToken = getAccessToken(); + if (!accessToken) return operation; + + return authUtilities.appendHeaders(operation, { Authorization: `Bearer ${accessToken}` }); + }, + didAuthError: (error) => { + return hasCatalogueCode(error, ERROR_CODES.TOKEN_EXPIRED); + }, + refreshAuth: async () => { + await queryClient.fetchQuery(refreshAccessTokenQueryOptions()).catch((error) => { + redirectToLogin(); + throw error; + }); + }, + }; +}); + +// Grows only with the branch/point-in-time endpoints a session visits, and dies with the page. +const clientsByEndpoint = new Map(); + +// urql's Client dedups concurrent operations by hash(query, variables) and ignores the URL. +// but this app carries the branch/point-in-time in the URL, not in variables. +// Without this, two concurrent identical query+variables on DIFFERENT branches share one network request +// and both receive one branch's data. +function getGraphqlClient(branch?: string | null, date?: Date | null): Client { + const url = CONFIG.GRAPHQL_URL(branch, date); + let client = clientsByEndpoint.get(url); + if (!client) { + client = new Client({ + url, + preferGetMethod: false, + fetchOptions: { + headers: { + [PRIORITY_HEADER]: DEFAULT_PRIORITY, + }, + }, + exchanges: [addTypenameExchange, authenticationExchange, fetchExchange], + }); + clientsByEndpoint.set(url, client); + } + return client; +} + +// Map urql result to the preserved `{ data, errors }` shape and run error routing. +function toGraphQLResult( + data: TData | undefined, + error: CombinedError | undefined, + context?: GraphQLRequestContext +): GraphQLResult { + handleGraphQLErrors(error, context); + + if (error?.networkError) { + throw error.networkError; + } + + if (error?.graphQLErrors?.length) { + throw new Error(error.graphQLErrors.map((e) => e.message).join("; "), { cause: error }); + } + + return { data: data as TData }; +} + +interface QueryArgs { + query: DocumentInput; + variables?: TVars; + context?: GraphQLRequestContext; +} + +interface MutateArgs { + mutation: DocumentInput; + variables?: TVars; + context?: GraphQLRequestContext; +} + +// The transport-only client the app depends on. +// Preserves the Apollo api: `query`/`mutate` returning `Promise<{ data, errors }>`. +export const graphqlClient = { + async query( + args: QueryArgs + ): Promise> { + const result = await getGraphqlClient(args.context?.branch, args.context?.date) + .query(args.query, args.variables as TVars) + .toPromise(); + return toGraphQLResult(result.data, result.error, args.context); + }, + + async mutate( + args: MutateArgs + ): Promise> { + const result = await getGraphqlClient(args.context?.branch, args.context?.date) + .mutation(args.mutation, args.variables as TVars) + .toPromise(); + return toGraphQLResult(result.data, result.error, args.context); + }, +}; diff --git a/frontend/app/src/shared/api/graphql/error-handling.ts b/frontend/app/src/shared/api/graphql/error-handling.ts new file mode 100644 index 00000000000..4390445cfb8 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/error-handling.ts @@ -0,0 +1,75 @@ +import type { CombinedError } from "@urql/core"; +import React from "react"; +import { toast } from "react-toastify"; + +import { ERROR_CODES, parseCatalogueError } from "@/shared/api/errors"; +import { ALERT_TYPES, Alert } from "@/shared/components/ui/alert"; + +import { redirectToLogin } from "@/entities/authentication/domain/use-cases/redirect-to-login"; + +import type { GraphQLRequestContext } from "./types"; + +export function hasCatalogueCode(error: CombinedError | undefined, code: string): boolean { + return ( + error?.graphQLErrors?.some((e) => parseCatalogueError(e.extensions).code === code) ?? false + ); +} + +function notifyUser(message: string | undefined, context?: GraphQLRequestContext): void { + if (!message) return; + + if (context?.processErrorMessage) { + context.processErrorMessage(message); + return; + } + + toast(React.createElement(Alert, { type: ALERT_TYPES.ERROR, message }), { + toastId: "alert-error", + }); +} + +export function handleGraphQLErrors( + error: CombinedError | undefined, + context?: GraphQLRequestContext +): void { + if (!error?.graphQLErrors?.length) return; + + for (const graphQLError of error.graphQLErrors) { + const parsed = parseCatalogueError(graphQLError.extensions); + + console.error( + `[GraphQL error]: Code: ${parsed.code}, Message: ${graphQLError.message}, ` + + `Location: ${JSON.stringify(graphQLError.locations)}, Path: ${graphQLError.path}` + ); + + switch (parsed.code) { + case ERROR_CODES.TOKEN_EXPIRED: + case ERROR_CODES.AUTHENTICATION_REQUIRED: { + redirectToLogin(); + return; + } + + case ERROR_CODES.PERMISSION_DENIED: { + // 403s are handled by route-level guards; `continue` so sibling errors still route. + continue; + } + + case ERROR_CODES.UNDEFINED_ERROR: { + // Unknown catalogue code: surface it loudly in dev so the gap gets registered. + if (import.meta.env.DEV) { + console.error( + "[catalogue gap] Unmatched error code surfaced as UNDEFINED_ERROR. " + + "Register it in backend/infrahub/errors/catalogue.py, regenerate " + + "the schema, and run `pnpm generate:error-bindings`.", + { message: graphQLError.message, extensions: graphQLError.extensions } + ); + } + notifyUser(graphQLError.message, context); + continue; + } + default: { + notifyUser(graphQLError.message, context); + } + } + } +} diff --git a/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts b/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts index 4df7a69d78e..fe65f17ad0f 100644 --- a/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts +++ b/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts @@ -1,202 +1,222 @@ /* eslint-disable */ /* prettier-ignore */ import type { TadaDocumentNode, $tada } from 'gql.tada'; +import { + type AnyVariables, + Client, + type CombinedError, + type DocumentInput, + fetchExchange, + formatDocument, + makeOperation, + mapExchange, +} from "@urql/core"; +import { authExchange } from "@urql/exchange-auth"; +import { ERROR_CODES } from "@/shared/api/errors"; +import { handleGraphQLErrors, hasCatalogueCode } from "@/shared/api/graphql/error-handling"; +import type { GraphQLRequestContext, GraphQLResult } from "@/shared/api/graphql/types"; +import { DEFAULT_PRIORITY, PRIORITY_HEADER } from "@/shared/api/priority"; +import { queryClient } from "@/shared/api/rest/client"; +import { CONFIG } from "@/shared/config/config"; +import { getAccessToken } from "@/entities/authentication/api/token-storage"; +import { redirectToLogin } from "@/entities/authentication/domain/use-cases/redirect-to-login"; +import { refreshAccessTokenQueryOptions } from "@/entities/authentication/ui/queries/refresh-access-token.query"; declare module 'gql.tada' { interface setupCache { - /** @gql.tada/hash sha256:b9518bf77f7eac981ea21a421adcc982 */ + /** @gql.tada/hash sha256:6376f0c50c7b9efc9b14b0ed96ef0d5d */ "\n mutation BRANCH_CREATE($name: String!, $description: String, $sync_with_git: Boolean) {\n BranchCreate(data: { name: $name, description: $description, sync_with_git: $sync_with_git }) {\n object {\n id\n name\n description\n origin_branch\n branched_from\n created_at\n status\n sync_with_git\n is_default\n status\n has_schema_changes\n }\n }\n }\n": TadaDocumentNode<{ BranchCreate: { object: { id: string; name: string; description: string | null; origin_branch: string | null; branched_from: string | null; created_at: string | null; status: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN"; sync_with_git: boolean | null; is_default: boolean | null; has_schema_changes: boolean | null; } | null; } | null; }, { sync_with_git?: boolean | null | undefined; description?: string | null | undefined; name: string; }, void>; - /** @gql.tada/hash sha256:62c12fc3bb1d5a7dcfacab297beb886b */ + /** @gql.tada/hash sha256:f5d5f680ab0073cb336b18c9ef53c662 */ "\n mutation BRANCH_DELETE($name: String, $deleteFromGit: Boolean) {\n BranchDelete(data: { name: $name, delete_from_git: $deleteFromGit }) {\n ok\n }\n }\n": TadaDocumentNode<{ BranchDelete: { ok: boolean | null; } | null; }, { deleteFromGit?: boolean | null | undefined; name?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:0f1d712fac5036b9320af2246f8be357 */ + /** @gql.tada/hash sha256:6e993f4bc5e40aa1eea81d1f8bf71365 */ "\n query GET_BRANCH_ACTION_STATE($branch: String!, $workflow: [String], $state: [StateType]) {\n InfrahubTask(branch: $branch, workflow: $workflow, state: $state) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; }; }, { state?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; workflow?: (string | null)[] | null | undefined; branch: string; }, void>; - /** @gql.tada/hash sha256:b3174e81ac6fb64d9577ab170db5c6e1 */ + /** @gql.tada/hash sha256:8985cd49104dc846e8edfec3cf6e2ee5 */ "\n query GetBranchDetails($branchName: String!) {\n InfrahubBranch(name__value: $branchName) {\n edges {\n node {\n id\n name {\n value\n }\n description {\n value\n }\n origin_branch {\n value\n }\n branched_from {\n value\n }\n status {\n value\n }\n created_at\n sync_with_git {\n value\n }\n is_default {\n value\n }\n has_schema_changes {\n value\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubBranch: { edges: { node: { id: string; name: { value: string; }; description: { value: string | null; } | null; origin_branch: { value: string | null; } | null; branched_from: { value: string | null; } | null; status: { value: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN"; }; created_at: string | null; sync_with_git: { value: boolean | null; } | null; is_default: { value: boolean | null; } | null; has_schema_changes: { value: boolean | null; } | null; }; }[]; }; }, { branchName: string; }, void>; - /** @gql.tada/hash sha256:632d9da3f5712d2da4d0b219921736ee */ + /** @gql.tada/hash sha256:503cfe90eaed9377db64a3b6e81e4c69 */ "\n query GetBranchesCount($nameValue: String, $partialMatch: Boolean, $statusValue: BranchStatus, $createdById: ID, $branchedFromAfter: DateTime, $branchedFromBefore: DateTime, $createdAtAfter: DateTime, $createdAtBefore: DateTime, $updatedAtAfter: DateTime, $updatedAtBefore: DateTime) {\n InfrahubBranch(name__value: $nameValue, partial_match: $partialMatch, status__value: $statusValue, node_metadata__created_by__id: $createdById, branched_from__after: $branchedFromAfter, branched_from__before: $branchedFromBefore, node_metadata__created_at__after: $createdAtAfter, node_metadata__created_at__before: $createdAtBefore, node_metadata__updated_at__after: $updatedAtAfter, node_metadata__updated_at__before: $updatedAtBefore) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubBranch: { count: number | null; }; }, { updatedAtBefore?: unknown; updatedAtAfter?: unknown; createdAtBefore?: unknown; createdAtAfter?: unknown; branchedFromBefore?: unknown; branchedFromAfter?: unknown; createdById?: string | null | undefined; statusValue?: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN" | null | undefined; partialMatch?: boolean | null | undefined; nameValue?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:491d94d63b86c8edcaa61a322d175cf1 */ + /** @gql.tada/hash sha256:2bd8c5324846f9ec6199e78f2eed6826 */ "\n query GetBranches($limit: Int, $offset: Int, $nameValue: String, $partialMatch: Boolean, $statusValue: BranchStatus, $createdById: ID, $branchedFromAfter: DateTime, $branchedFromBefore: DateTime, $createdAtAfter: DateTime, $createdAtBefore: DateTime, $updatedAtAfter: DateTime, $updatedAtBefore: DateTime) {\n InfrahubBranch(limit: $limit, offset: $offset, name__value: $nameValue, partial_match: $partialMatch, status__value: $statusValue, node_metadata__created_by__id: $createdById, branched_from__after: $branchedFromAfter, branched_from__before: $branchedFromBefore, node_metadata__created_at__after: $createdAtAfter, node_metadata__created_at__before: $createdAtBefore, node_metadata__updated_at__after: $updatedAtAfter, node_metadata__updated_at__before: $updatedAtBefore) {\n edges {\n node {\n id\n name {\n value\n }\n description {\n value\n }\n origin_branch {\n value\n }\n branched_from {\n value\n }\n status {\n value\n }\n created_at\n sync_with_git {\n value\n }\n is_default {\n value\n }\n has_schema_changes {\n value\n }\n }\n node_metadata {\n created_at\n created_by {\n id\n display_label\n hfid\n __typename\n }\n updated_at\n updated_by {\n id\n display_label\n hfid\n __typename\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubBranch: { edges: { node: { id: string; name: { value: string; }; description: { value: string | null; } | null; origin_branch: { value: string | null; } | null; branched_from: { value: string | null; } | null; status: { value: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN"; }; created_at: string | null; sync_with_git: { value: boolean | null; } | null; is_default: { value: boolean | null; } | null; has_schema_changes: { value: boolean | null; } | null; }; node_metadata: { created_at: unknown; created_by: { __typename: "CoreAccount"; id: string | null; display_label: string | null; hfid: string[] | null; } | null; updated_at: unknown; updated_by: { __typename: "CoreAccount"; id: string | null; display_label: string | null; hfid: string[] | null; } | null; }; }[]; }; }, { updatedAtBefore?: unknown; updatedAtAfter?: unknown; createdAtBefore?: unknown; createdAtAfter?: unknown; branchedFromBefore?: unknown; branchedFromAfter?: unknown; createdById?: string | null | undefined; statusValue?: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN" | null | undefined; partialMatch?: boolean | null | undefined; nameValue?: string | null | undefined; offset?: number | null | undefined; limit?: number | null | undefined; }, void>; - /** @gql.tada/hash sha256:9190339d4b3488628a28fc2ad01d51a7 */ + /** @gql.tada/hash sha256:3bf6c59ad7574b7c51d6506ca7a4537a */ "\n mutation BRANCH_MERGE($name: String) {\n BranchMerge(wait_until_completion: false, data: { name: $name }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ BranchMerge: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { name?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:ddb5c76afb83f46f222533ad490098f8 */ + /** @gql.tada/hash sha256:031071ccc0b72d4dea7b3c89e18330c9 */ "\n mutation BRANCH_REBASE($name: String, $waitUntilCompletion: Boolean!) {\n BranchRebase(wait_until_completion: $waitUntilCompletion, data: { name: $name }) {\n ok\n object {\n id\n name\n description\n origin_branch\n branched_from\n created_at\n status\n sync_with_git\n is_default\n has_schema_changes\n }\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ BranchRebase: { ok: boolean | null; object: { id: string; name: string; description: string | null; origin_branch: string | null; branched_from: string | null; created_at: string | null; status: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN"; sync_with_git: boolean | null; is_default: boolean | null; has_schema_changes: boolean | null; } | null; task: { id: string | null; } | null; } | null; }, { waitUntilCompletion: boolean; name?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:403415460b0f7ee18b17d7b49a8c6a4f */ + /** @gql.tada/hash sha256:21a704d6a422f480ed6934b390e68e8a */ "\n mutation BRANCH_VALIDATE($name: String) {\n BranchValidate(wait_until_completion: false, data: { name: $name }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ BranchValidate: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { name?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:1a7c0cec5e09ac457f1825ffee71cb21 */ + /** @gql.tada/hash sha256:33a6ebb314529b3bd1240c40a1f30aa4 */ "\n query GET_ARTIFACT_THREADS($changeIds: [ID!]) {\n CoreArtifactThread(change__ids: $changeIds) {\n count\n edges {\n node {\n id\n display_label\n __typename\n line_number {\n value\n }\n storage_id {\n value\n }\n resolved {\n value\n }\n comments {\n edges {\n node_metadata {\n created_at\n created_by {\n display_label\n }\n }\n node {\n id\n text {\n value\n }\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreArtifactThread: { count: number; edges: { node: { id: string; display_label: string | null; __typename: "CoreArtifactThread"; line_number: { value: unknown; } | null; storage_id: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; text: { value: string | null; } | null; } | null; }[]; }; } | null; }[]; }; }, { changeIds?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:02d834250fc0cdbf9a5ac41060465384 */ + /** @gql.tada/hash sha256:7c1cfe0b230dadc281edcaea7520c8c7 */ "\n query GET_CHECK_DETAILS($id: ID!) {\n CoreCheck(ids: [$id]) {\n edges {\n node {\n id\n display_label\n name {\n value\n }\n message {\n value\n }\n severity {\n value\n }\n conclusion {\n value\n }\n kind {\n value\n }\n origin {\n value\n }\n created_at {\n value\n }\n ... on CoreDataCheck {\n conflicts {\n value\n }\n keep_branch {\n value\n }\n }\n ... on CoreSchemaCheck {\n conflicts {\n value\n }\n }\n ... on CoreFileCheck {\n files {\n value\n }\n commit {\n value\n }\n }\n ... on CoreArtifactCheck {\n storage_id {\n value\n }\n artifact_id {\n value\n }\n }\n __typename\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreCheck: { edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; keep_branch: { value: string | null; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[]; }; }, { id: string; }, void>; - /** @gql.tada/hash sha256:c080adf2b3487b914801514bd6c946e7 */ + /** @gql.tada/hash sha256:18451e11a2f079fda43792dc178d6cc8 */ "\n query GET_OBJECT_THREAD_COMMENTS($changeIds: [ID!], $objectPath: String) {\n CoreObjectThread(change__ids: $changeIds, object_path__value: $objectPath) {\n count\n edges {\n node {\n __typename\n id\n display_label\n resolved {\n value\n }\n comments {\n count\n edges {\n node_metadata {\n created_at\n created_by {\n display_label\n }\n }\n node {\n id\n display_label\n text {\n value\n }\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreObjectThread: { count: number; edges: { node: { __typename: "CoreObjectThread"; id: string; display_label: string | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; } | null; }[]; }; }, { objectPath?: string | null | undefined; changeIds?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:7653ae0398ff65967db09adcee2e4694 */ + /** @gql.tada/hash sha256:da1f47a581bcb197597d010dcf74b67a */ "\n query GET_OBJECT_THREADS($changeIds: [ID!], $objectPath: String) {\n CoreObjectThread(change__ids: $changeIds, object_path__value: $objectPath) {\n count\n edges {\n node {\n __typename\n id\n comments {\n count\n }\n }\n }\n permissions {\n edges {\n node {\n kind\n view\n create\n update\n delete\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreObjectThread: { count: number; edges: { node: { __typename: "CoreObjectThread"; id: string; comments: { count: number; }; } | null; }[]; permissions: { edges: { node: { kind: string; view: "ALLOW" | "ALLOW_DEFAULT" | "ALLOW_OTHER" | "DENY"; create: "ALLOW" | "ALLOW_DEFAULT" | "ALLOW_OTHER" | "DENY"; update: "ALLOW" | "ALLOW_DEFAULT" | "ALLOW_OTHER" | "DENY"; delete: "ALLOW" | "ALLOW_DEFAULT" | "ALLOW_OTHER" | "DENY"; }; }[]; }; }; }, { objectPath?: string | null | undefined; changeIds?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:1717ffaa5e5b73f659e8f4259027deb4 */ + /** @gql.tada/hash sha256:33b8a6398265390f0bdbf1da1b39ddd3 */ "\n query GET_DIFF_TREE($branchName: String, $filters: DiffTreeQueryFilters, $limit: Int, $offset: Int, $proposedChangeId: String) {\n DiffTree(branch: $branchName, filters: $filters, include_parents: true, limit: $limit, offset: $offset, proposed_change_id: $proposedChangeId) {\n nodes {\n uuid\n relationships {\n label\n status\n contains_conflict\n cardinality\n elements {\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n base_branch_value\n diff_branch_label\n diff_branch_action\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n last_changed_at\n contains_conflict\n peer_id\n properties {\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n base_branch_value\n diff_branch_label\n diff_branch_action\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n last_changed_at\n new_value\n previous_value\n property_type\n status\n path_identifier\n }\n status\n path_identifier\n peer_label\n }\n last_changed_at\n name\n path_identifier\n }\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n diff_branch_action\n diff_branch_label\n base_branch_value\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n attributes {\n contains_conflict\n last_changed_at\n name\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n base_branch_value\n diff_branch_label\n diff_branch_action\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n properties {\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n base_branch_value\n diff_branch_label\n diff_branch_action\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n last_changed_at\n new_value\n previous_value\n property_type\n status\n path_identifier\n }\n status\n path_identifier\n }\n kind\n contains_conflict\n label\n last_changed_at\n status\n path_identifier\n parent {\n uuid\n relationship_name\n kind\n }\n }\n to_time\n base_branch\n diff_branch\n from_time\n }\n }\n": TadaDocumentNode<{ DiffTree: { nodes: { uuid: string; relationships: { label: string | null; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; contains_conflict: boolean; cardinality: "MANY" | "ONE"; elements: { conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; base_branch_value: string | null; diff_branch_label: string | null; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; last_changed_at: unknown; contains_conflict: boolean; peer_id: string; properties: { conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; base_branch_value: string | null; diff_branch_label: string | null; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; last_changed_at: unknown; new_value: string | null; previous_value: string | null; property_type: string; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; }[] | null; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; peer_label: string | null; }[]; last_changed_at: unknown; name: string; path_identifier: string; }[]; conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_label: string | null; base_branch_value: string | null; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; attributes: { contains_conflict: boolean; last_changed_at: unknown; name: string; conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; base_branch_value: string | null; diff_branch_label: string | null; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; properties: { conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; base_branch_value: string | null; diff_branch_label: string | null; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; last_changed_at: unknown; new_value: string | null; previous_value: string | null; property_type: string; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; }[] | null; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; }[]; kind: string; contains_conflict: boolean; label: string; last_changed_at: unknown; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; parent: { uuid: string; relationship_name: string | null; kind: string | null; } | null; }[] | null; to_time: unknown; base_branch: string; diff_branch: string; from_time: unknown; } | null; }, { proposedChangeId?: string | null | undefined; offset?: number | null | undefined; limit?: number | null | undefined; filters?: { status?: { includes?: ("ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED" | null)[] | null | undefined; excludes?: ("ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED" | null)[] | null | undefined; } | null | undefined; namespace?: { includes?: (string | null)[] | null | undefined; excludes?: (string | null)[] | null | undefined; } | null | undefined; kind?: { includes?: (string | null)[] | null | undefined; excludes?: (string | null)[] | null | undefined; } | null | undefined; ids?: (string | null)[] | null | undefined; } | null | undefined; branchName?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:cc12b829e276df170c55e69749e7a7ce */ + /** @gql.tada/hash sha256:ef28f14ab41d73e80d90cefb8a677b8e */ "\n query GET_DIFF_TREE_SUMMARY($branch: String, $filters: DiffTreeQueryFilters, $proposedChangeId: String) {\n DiffTreeSummary(branch: $branch, filters: $filters, proposed_change_id: $proposedChangeId) {\n num_added\n num_updated\n num_removed\n num_conflicts\n }\n }\n": TadaDocumentNode<{ DiffTreeSummary: { num_added: number; num_updated: number; num_removed: number; num_conflicts: number; } | null; }, { proposedChangeId?: string | null | undefined; filters?: { status?: { includes?: ("ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED" | null)[] | null | undefined; excludes?: ("ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED" | null)[] | null | undefined; } | null | undefined; namespace?: { includes?: (string | null)[] | null | undefined; excludes?: (string | null)[] | null | undefined; } | null | undefined; kind?: { includes?: (string | null)[] | null | undefined; excludes?: (string | null)[] | null | undefined; } | null | undefined; ids?: (string | null)[] | null | undefined; } | null | undefined; branch?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:902068910544cf8ac3c482eca6348853 */ + /** @gql.tada/hash sha256:6c62e3f8d4cfdff3baf194f3dbe4f278 */ "\n query GET_FILE_THREADS($changeIds: [ID!]) {\n CoreFileThread(change__ids: $changeIds) {\n count\n edges {\n node {\n id\n display_label\n resolved {\n value\n }\n __typename\n file {\n value\n }\n commit {\n value\n }\n repository {\n node {\n id\n }\n }\n line_number {\n value\n }\n comments {\n edges {\n node_metadata {\n created_at\n created_by {\n display_label\n }\n }\n node {\n id\n text {\n value\n }\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreFileThread: { count: number; edges: { node: { id: string; display_label: string | null; resolved: { value: boolean | null; } | null; __typename: "CoreFileThread"; file: { value: string | null; } | null; commit: { value: string | null; } | null; repository: { node: { id: string; } | null; }; line_number: { value: unknown; } | null; comments: { edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; text: { value: string | null; } | null; } | null; }[]; }; } | null; }[]; }; }, { changeIds?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:f96f047ff5111e7dbfda711382b37841 */ + /** @gql.tada/hash sha256:d389b807e031bc585807e6cc8f2b06e9 */ "\n query GET_VALIDATOR_DETAILS($ids: [ID!], $checksOffset: Int, $checksLimit: Int) {\n CoreValidator(ids: $ids) {\n edges {\n node {\n id\n display_label\n conclusion {\n value\n }\n started_at {\n value\n }\n completed_at {\n value\n }\n state {\n value\n }\n ... on CoreRepositoryValidator {\n repository {\n node {\n display_label\n }\n }\n }\n ... on CoreArtifactValidator {\n definition {\n node {\n display_label\n name {\n value\n }\n description {\n value\n }\n }\n }\n }\n checks(offset: $checksOffset, limit: $checksLimit) {\n count\n edges {\n node {\n id\n display_label\n name {\n value\n }\n message {\n value\n }\n severity {\n value\n }\n conclusion {\n value\n }\n kind {\n value\n }\n origin {\n value\n }\n created_at {\n value\n }\n ... on CoreDataCheck {\n conflicts {\n value\n }\n }\n ... on CoreSchemaCheck {\n conflicts {\n value\n }\n }\n ... on CoreFileCheck {\n files {\n value\n }\n commit {\n value\n }\n }\n ... on CoreArtifactCheck {\n storage_id {\n value\n }\n artifact_id {\n value\n }\n }\n __typename\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreValidator: { edges: { node: { __typename?: "CoreArtifactValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; definition: { node: { display_label: string | null; name: { value: string | null; } | null; description: { value: string | null; } | null; } | null; }; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreDataValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreGeneratorValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreRepositoryValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; repository: { node: { __typename?: "CoreReadOnlyRepository" | undefined; display_label: string | null; } | { __typename?: "CoreRepository" | undefined; display_label: string | null; } | null; }; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreSchemaValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreUserValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | null; }[]; }; }, { checksLimit?: number | null | undefined; checksOffset?: number | null | undefined; ids?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:386da0e74783828056dc26c5d8e45175 */ + /** @gql.tada/hash sha256:e5a95985d66e66b26c4d68b8a1b7db91 */ "\n query GET_CORE_VALIDATORS($id: ID!) {\n CoreValidator(proposed_change__ids: [$id]) {\n edges {\n node {\n id\n display_label\n conclusion {\n value\n }\n started_at {\n value\n }\n completed_at {\n value\n }\n state {\n value\n }\n checks {\n edges {\n node {\n conclusion {\n value\n }\n severity {\n value\n }\n }\n }\n }\n ... on CoreArtifactValidator {\n definition {\n node {\n id\n display_label\n __typename\n }\n }\n }\n __typename\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreValidator: { edges: { node: { __typename: "CoreArtifactValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; definition: { node: { id: string; display_label: string | null; __typename: "CoreArtifactDefinition"; } | null; }; } | { __typename: "CoreDataValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename: "CoreGeneratorValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename: "CoreRepositoryValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename: "CoreSchemaValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename: "CoreUserValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | null; }[]; }; }, { id: string; }, void>; - /** @gql.tada/hash sha256:fdafb6ef86381bb0c7f1dd329656009b */ + /** @gql.tada/hash sha256:7610f1f629cb0e3c7a955a4ce08ac712 */ "\n mutation RESOLVE_CONFLICT($id: String, $selection: ConflictSelection) {\n ResolveDiffConflict(data: { conflict_id: $id, selected_branch: $selection }) {\n ok\n }\n }\n": TadaDocumentNode<{ ResolveDiffConflict: { ok: boolean | null; } | null; }, { selection?: "BASE_BRANCH" | "DIFF_BRANCH" | null | undefined; id?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:864e8db2b4a698880840769e5a052d4e */ + /** @gql.tada/hash sha256:3ac40a857b94f9ee8efe48b881111132 */ "\n mutation RUN_CHECK($proposedChangeId: String!, $checkType: CheckType) {\n CoreProposedChangeRunCheck(data: { id: $proposedChangeId, check_type: $checkType }) {\n ok\n }\n }\n": TadaDocumentNode<{ CoreProposedChangeRunCheck: { ok: boolean | null; } | null; }, { checkType?: "ALL" | "ARTIFACT" | "DATA" | "GENERATOR" | "REPOSITORY" | "SCHEMA" | "TEST" | "USER" | null | undefined; proposedChangeId: string; }, void>; - /** @gql.tada/hash sha256:1595e83bbe8c3bbf3ea477ad75c3d792 */ + /** @gql.tada/hash sha256:db0c714f77dbfcca5babe803cdc4f611 */ "\n mutation DIFF_UPDATE($branchName: String!, $waitUntilCompletion: Boolean) {\n DiffUpdate(data: { branch: $branchName }, wait_until_completion: $waitUntilCompletion) {\n ok\n }\n }\n": TadaDocumentNode<{ DiffUpdate: { ok: boolean | null; } | null; }, { waitUntilCompletion?: boolean | null | undefined; branchName: string; }, void>; - /** @gql.tada/hash sha256:d5b712a60c1b3ec6d4fa61a8521a3d21 */ + /** @gql.tada/hash sha256:8da0f1a12ca2b6cb15d594d6fe1a42e8 */ "\n query GET_INFRAHUB_EVENTS(\n $ids: [String!]\n $hasChildren: Boolean\n $branches: [String!]\n $eventType: [String!]\n $primaryNodeIds: [String!]\n $relatedNodeIds: [String!]\n $parentIds: [String!]\n $accountIds: [String!]\n $level: Int\n $since: DateTime\n $until: DateTime\n $offset: Int\n $limit: Int\n $order: EventSortOrder\n ) {\n InfrahubEvent(\n ids: $ids\n has_children: $hasChildren\n branches: $branches\n event_type: $eventType\n primary_node__ids: $primaryNodeIds\n related_node__ids: $relatedNodeIds\n parent__ids: $parentIds\n account__ids: $accountIds\n level: $level\n since: $since\n until: $until\n offset: $offset\n limit: $limit\n order: $order\n ) {\n edges {\n node {\n id\n event\n branch\n occurred_at\n level\n account_id\n primary_node {\n id\n kind\n }\n related_nodes {\n id\n kind\n }\n has_children\n __typename\n ... on NodeMutatedEvent {\n attributes {\n action\n kind\n name\n value\n value_previous\n }\n relationships {\n action\n name\n peer {\n id\n kind\n }\n }\n payload\n }\n ... on StandardEvent {\n payload\n }\n ... on BranchCreatedEvent {\n payload\n created_branch\n }\n ... on BranchDeletedEvent {\n payload\n deleted_branch\n }\n ... on BranchRebasedEvent {\n payload\n rebased_branch\n }\n ... on BranchMergedEvent {\n source_branch\n }\n ... on GroupEvent {\n ancestors {\n id\n kind\n }\n members {\n id\n kind\n }\n }\n ... on ArtifactEvent {\n checksum\n storage_id\n artifact_definition_id\n checksum_previous\n storage_id_previous\n }\n ... on AccountLoggedInEventType {\n account_name\n account_type\n auth_method\n session_id\n timestamp\n client_ip\n user_agent\n groups\n roles\n identity_source\n }\n ... on AccountLoggedOutEventType {\n account_name\n logout_type\n session_id\n timestamp\n client_ip\n user_agent\n }\n ... on GroupAutoCreatedEventType {\n idp\n protocol\n triggering_user_id\n triggering_user_name\n group_id\n group_name\n source_pattern\n origin_value\n }\n ... on GroupAutoCreateRejectedEventType {\n idp\n protocol\n triggering_user_id\n triggering_user_name\n rejected_claim_value\n }\n ... on GroupAutoCreateCappedEventType {\n idp\n protocol\n triggering_user_id\n triggering_user_name\n cap_value\n dropped_count\n dropped_claims\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubEvent: { edges: { node: { __typename: "AccountLoggedInEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; account_name: string; account_type: string; auth_method: string; session_id: string; timestamp: unknown; client_ip: string | null; user_agent: string | null; groups: string[]; roles: string[]; identity_source: string | null; } | { __typename: "AccountLoggedOutEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; account_name: string; logout_type: string; session_id: string; timestamp: unknown; client_ip: string | null; user_agent: string | null; } | { __typename: "ArtifactEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; checksum: string; storage_id: string; artifact_definition_id: string; checksum_previous: string | null; storage_id_previous: string | null; } | { __typename: "BranchCreatedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; payload: unknown; created_branch: string; } | { __typename: "BranchDeletedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; payload: unknown; deleted_branch: string; } | { __typename: "BranchMergedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; source_branch: string; } | { __typename: "BranchRebasedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; payload: unknown; rebased_branch: string; } | { __typename: "GroupAutoCreateCappedEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; idp: string; protocol: string; triggering_user_id: string; triggering_user_name: string; cap_value: number; dropped_count: number; dropped_claims: string[]; } | { __typename: "GroupAutoCreateRejectedEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; idp: string; protocol: string; triggering_user_id: string; triggering_user_name: string; rejected_claim_value: string; } | { __typename: "GroupAutoCreatedEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; idp: string; protocol: string; triggering_user_id: string; triggering_user_name: string; group_id: string; group_name: string; source_pattern: string; origin_value: string; } | { __typename: "GroupEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; ancestors: { id: string; kind: string; }[]; members: { id: string; kind: string; }[]; } | { __typename: "NodeMutatedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; attributes: { action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; kind: string; name: string; value: string | null; value_previous: string | null; }[]; relationships: { action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; name: string; peer: { id: string; kind: string; }; }[]; payload: unknown; } | { __typename: "ProposedChangeApprovalsRevokedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeMergedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeReviewEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeReviewRequestedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeReviewRevokedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeThreadEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "StandardEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; payload: unknown; } | null; }[]; }; }, { order?: "ASC" | "DESC" | null | undefined; limit?: number | null | undefined; offset?: number | null | undefined; until?: unknown; since?: unknown; level?: number | null | undefined; accountIds?: string[] | null | undefined; parentIds?: string[] | null | undefined; relatedNodeIds?: string[] | null | undefined; primaryNodeIds?: string[] | null | undefined; eventType?: string[] | null | undefined; branches?: string[] | null | undefined; hasChildren?: boolean | null | undefined; ids?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:5019dcdb2c4779bca73d4392dcc7e21c */ + /** @gql.tada/hash sha256:ffecab70c64680ca89fe5f25396cb0c0 */ "\n mutation CoreGeneratorDefinitionRun($generatorId: String!, $waitUntilCompletion: Boolean, $targetNodeIds: [String!]) {\n CoreGeneratorDefinitionRun(\n wait_until_completion: $waitUntilCompletion\n data: { id: $generatorId, nodes: $targetNodeIds }\n ) {\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ CoreGeneratorDefinitionRun: { task: { id: string | null; } | null; } | null; }, { targetNodeIds?: string[] | null | undefined; waitUntilCompletion?: boolean | null | undefined; generatorId: string; }, void>; - /** @gql.tada/hash sha256:71e28047d3fd3fa5ece772b10603fe9f */ + /** @gql.tada/hash sha256:ca95cf9f3c855b134a6fa00d17c7bcd8 */ "\n query getNextIPAddressAvailable($parentPrefixId: String!) {\n InfrahubIPAddressGetNextAvailable(prefix_id: $parentPrefixId) {\n address\n }\n }\n": TadaDocumentNode<{ InfrahubIPAddressGetNextAvailable: { address: string; }; }, { parentPrefixId: string; }, void>; - /** @gql.tada/hash sha256:86dae3436276b832a565e3530e7c8a59 */ + /** @gql.tada/hash sha256:3e1b64ad8b9a0059dd5a6ddcb0c31411 */ "\n query getNextIPPrefixAvailable($parentPrefixId: String!) {\n InfrahubIPPrefixGetNextAvailable(prefix_id: $parentPrefixId) {\n prefix\n }\n }\n": TadaDocumentNode<{ InfrahubIPPrefixGetNextAvailable: { prefix: string; }; }, { parentPrefixId: string; }, void>; - /** @gql.tada/hash sha256:b8a6acd6071b7f4626853517597164d9 */ + /** @gql.tada/hash sha256:26ec8fff6f08a34edc3ae1b4c52e3add */ "\n query GET_IPAM_TREE_NODES(\n $isTopLevel: Boolean\n $parentIds: [ID!]\n $search: String\n $ipNamespaceIds: [ID!]\n $limit: Int\n $offset: Int\n ) {\n BuiltinIPPrefix(\n is_top_level__value: $isTopLevel\n parent__ids: $parentIds\n any__value: $search\n partial_match: true\n ip_namespace__ids: $ipNamespaceIds\n offset: $offset\n limit: $limit\n ) {\n edges {\n node {\n id\n display_label\n descendants {\n count\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ BuiltinIPPrefix: { edges: { node: { __typename?: "InternalIPPrefixAvailable" | undefined; id: string | null; display_label: string | null; descendants: { count: number; }; } | null; }[]; }; }, { offset?: number | null | undefined; limit?: number | null | undefined; ipNamespaceIds?: string[] | null | undefined; search?: string | null | undefined; parentIds?: string[] | null | undefined; isTopLevel?: boolean | null | undefined; }, void>; - /** @gql.tada/hash sha256:a553facc93e909db54249f408783a55a */ + /** @gql.tada/hash sha256:a952fb9a9f599f592b09575679887045 */ "\n query Search($search: String!, $caseSensitive: Boolean) {\n InfrahubSearchAnywhere(q: $search, limit: 4, partial_match: true, case_sensitive: $caseSensitive) {\n count\n edges {\n node {\n id\n kind\n }\n }\n parent_prefixes {\n node {\n id\n kind\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubSearchAnywhere: { count: number; edges: { node: { id: string; kind: string; }; }[]; parent_prefixes: { node: { id: string; kind: string; }; }[] | null; }; }, { caseSensitive?: boolean | null | undefined; search: string; }, void>; - /** @gql.tada/hash sha256:f8b33c42350bc7cee77ef2f1c7e8fcbe */ + /** @gql.tada/hash sha256:ecc00afd13ac996c05bbafee0dab9a6c */ "\n mutation CONVERT_OBJECT_MUTATION($nodeId: String!, $targetKind: String!, $fieldsMapping: GenericScalar!) {\n ConvertObjectType(\n data: { node_id: $nodeId, target_kind: $targetKind, fields_mapping: $fieldsMapping }\n ) {\n node\n }\n }\n": TadaDocumentNode<{ ConvertObjectType: { node: unknown; } | null; }, { fieldsMapping: unknown; targetKind: string; nodeId: string; }, void>; - /** @gql.tada/hash sha256:bd382d3f0f66ced1dfea0a2985684da6 */ + /** @gql.tada/hash sha256:0776d80973dade8a4c8fc2fa5f2ace6d */ "\n query GET_FIELDS_MAPPING($sourceKind: String!, $targetKind: String!) {\n FieldsMappingTypeConversion(source_kind: $sourceKind, target_kind: $targetKind) {\n mapping\n }\n }\n": TadaDocumentNode<{ FieldsMappingTypeConversion: { mapping: unknown; }; }, { targetKind: string; sourceKind: string; }, void>; - /** @gql.tada/hash sha256:7f460ef0a53576a477f652e363742a31 */ + /** @gql.tada/hash sha256:f8525b8c3b94415a45052c43d63ec973 */ "\n mutation RelationshipAdd(\n $objectId: String!\n $relationshipName: String!\n $relationshipIds: [RelatedNodeInput]\n ) {\n RelationshipAdd(data: { id: $objectId, name: $relationshipName, nodes: $relationshipIds }) {\n ok\n }\n }\n": TadaDocumentNode<{ RelationshipAdd: { ok: boolean | null; } | null; }, { relationshipIds?: ({ kind?: string | null | undefined; id?: string | null | undefined; hfid?: (string | null)[] | null | undefined; from_pool?: { identifier?: string | null | undefined; id: string; data?: unknown; } | null | undefined; _relation__source?: string | null | undefined; _relation__owner?: string | null | undefined; _relation__is_protected?: boolean | null | undefined; } | null)[] | null | undefined; relationshipName: string; objectId: string; }, void>; - /** @gql.tada/hash sha256:f0a68116f6b3f78ad51a943f74e660e7 */ + /** @gql.tada/hash sha256:a34fc69963723135285b7f2799e05531 */ "\n mutation RelationshipRemove(\n $objectId: String!\n $relationshipName: String!\n $relationshipIds: [RelatedNodeInput]\n ) {\n RelationshipRemove(data: { id: $objectId, name: $relationshipName, nodes: $relationshipIds }) {\n ok\n }\n }\n": TadaDocumentNode<{ RelationshipRemove: { ok: boolean | null; } | null; }, { relationshipIds?: ({ kind?: string | null | undefined; id?: string | null | undefined; hfid?: (string | null)[] | null | undefined; from_pool?: { identifier?: string | null | undefined; id: string; data?: unknown; } | null | undefined; _relation__source?: string | null | undefined; _relation__owner?: string | null | undefined; _relation__is_protected?: boolean | null | undefined; } | null)[] | null | undefined; relationshipName: string; objectId: string; }, void>; - /** @gql.tada/hash sha256:0b3bba543485d453046bec107625a5fd */ + /** @gql.tada/hash sha256:1d717df5d131566bb2457c83e0b21ab0 */ "\n query InfrahubGlobalPermissions {\n InfrahubPermissions {\n global_permissions {\n edges {\n node {\n action\n decision\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubPermissions: { global_permissions: { edges: { node: { action: string; decision: string; }; }[]; } | null; }; }, {}, void>; - /** @gql.tada/hash sha256:1d5acd4ed75181470ceb3ef7e0b7443b */ + /** @gql.tada/hash sha256:5ca954bbd43213725a7dc186eaf10224 */ "\n query InfrahubEffectivePreferences {\n InfrahubEffectivePreferences {\n date_format {\n value\n source\n }\n timezone {\n value\n source\n }\n }\n }\n": - TadaDocumentNode<{ InfrahubEffectivePreferences: { date_format: { value: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; source: "DEFAULT" | "GLOBAL" | "USER"; }; timezone: { value: string | null; source: "DEFAULT" | "GLOBAL" | "USER"; }; }; }, {}, void>; - /** @gql.tada/hash sha256:edf4287171621261eef831ea1441c436 */ + TadaDocumentNode<{ InfrahubEffectivePreferences: { date_format: { value: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; source: "USER" | "DEFAULT" | "GLOBAL"; }; timezone: { value: string | null; source: "USER" | "DEFAULT" | "GLOBAL"; }; }; }, {}, void>; + /** @gql.tada/hash sha256:a237265ec2414a2290156056982f242c */ "\n query InfrahubGlobalPreferences {\n InfrahubGlobalPreferences {\n date_format\n timezone\n }\n }\n": TadaDocumentNode<{ InfrahubGlobalPreferences: { date_format: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; timezone: string | null; }; }, {}, void>; - /** @gql.tada/hash sha256:ca14ef01cc570afa5433b3e016eef4b3 */ + /** @gql.tada/hash sha256:c41d40d323b5c036211c168de8ef908d */ "\n mutation UpdateGlobalPreference($dateFormat: DateFormat, $timezone: String) {\n InfrahubSetPreferences(scope: GLOBAL, date_format: $dateFormat, timezone: $timezone) {\n ok\n date_format\n timezone\n }\n }\n": TadaDocumentNode<{ InfrahubSetPreferences: { ok: boolean | null; date_format: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; timezone: string | null; } | null; }, { timezone?: string | null | undefined; dateFormat?: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null | undefined; }, void>; - /** @gql.tada/hash sha256:8d9e32c82fc6667a421bd1c5801a1d12 */ + /** @gql.tada/hash sha256:c6fa11a01af80027c46eca662bd67d9e */ "\n mutation UpsertUserPreference($dateFormat: DateFormat, $timezone: String) {\n InfrahubSetPreferences(scope: USER, date_format: $dateFormat, timezone: $timezone) {\n ok\n date_format\n timezone\n }\n }\n": TadaDocumentNode<{ InfrahubSetPreferences: { ok: boolean | null; date_format: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; timezone: string | null; } | null; }, { timezone?: string | null | undefined; dateFormat?: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null | undefined; }, void>; - /** @gql.tada/hash sha256:7f7bf8aadeeee4d139c8eb81a004793f */ + /** @gql.tada/hash sha256:f852cef35f0c467c6c9621b187803e4d */ "\n mutation CoreProposedChangeCreate(\n $name: String!\n $isDraft: Boolean\n $description: String\n $source_branch: String!\n $destination_branch: String!\n $reviewers: [RelatedNodeInput!]\n ) {\n CoreProposedChangeCreate(\n data: {\n name: { value: $name }\n is_draft: { value: $isDraft }\n description: { value: $description }\n source_branch: { value: $source_branch }\n destination_branch: { value: $destination_branch }\n reviewers: $reviewers\n }\n ) {\n object {\n id\n display_label\n }\n ok\n }\n }\n": TadaDocumentNode<{ CoreProposedChangeCreate: { object: { id: string; display_label: string | null; } | null; ok: boolean | null; } | null; }, { reviewers?: { kind?: string | null | undefined; id?: string | null | undefined; hfid?: (string | null)[] | null | undefined; from_pool?: { identifier?: string | null | undefined; id: string; data?: unknown; } | null | undefined; _relation__source?: string | null | undefined; _relation__owner?: string | null | undefined; _relation__is_protected?: boolean | null | undefined; }[] | null | undefined; destination_branch: string; source_branch: string; description?: string | null | undefined; isDraft?: boolean | null | undefined; name: string; }, void>; - /** @gql.tada/hash sha256:485b74e9a3e4c127bc0dd5f376bd57b8 */ + /** @gql.tada/hash sha256:0e4d83df22ec13ae902b09c5ae316403 */ "\n query GET_PROPOSED_CHANGE_DETAILS($proposedChangeId: ID) {\n CoreProposedChange(ids: [$proposedChangeId]) {\n count\n edges {\n node_metadata {\n created_at\n created_by {\n id\n hfid\n display_label\n __typename\n }\n updated_at\n updated_by {\n id\n hfid\n display_label\n __typename\n }\n }\n node {\n id\n display_label\n __typename\n name {\n value\n }\n description {\n value\n updated_at\n }\n source_branch {\n value\n }\n destination_branch {\n value\n }\n state {\n value\n }\n is_draft {\n value\n }\n approved_by {\n edges {\n node {\n id\n display_label\n }\n }\n }\n rejected_by {\n edges {\n node {\n id\n display_label\n }\n }\n }\n reviewers {\n edges {\n node {\n id\n display_label\n }\n }\n }\n comments {\n count\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreProposedChange: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename: "CoreAccount"; id: string | null; hfid: string[] | null; display_label: string | null; } | null; updated_at: unknown; updated_by: { __typename: "CoreAccount"; id: string | null; hfid: string[] | null; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; __typename: "CoreProposedChange"; name: { value: string | null; } | null; description: { value: string | null; updated_at: unknown; } | null; source_branch: { value: string | null; } | null; destination_branch: { value: string | null; } | null; state: { value: string | null; } | null; is_draft: { value: boolean | null; } | null; approved_by: { edges: { node: { __typename?: "CoreAccount" | undefined; id: string | null; display_label: string | null; } | null; }[] | null; }; rejected_by: { edges: { node: { __typename?: "CoreAccount" | undefined; id: string | null; display_label: string | null; } | null; }[] | null; }; reviewers: { edges: { node: { __typename?: "CoreAccount" | undefined; id: string | null; display_label: string | null; } | null; }[] | null; }; comments: { count: number; }; } | null; }[]; }; }, { proposedChangeId?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:93f2ad535478e7a0c1754d7fb6632530 */ + /** @gql.tada/hash sha256:8ba5cb5ce48d31287647e3a501efb64a */ "\n query GetCoreThread($ids: [ID]) {\n CoreThread(ids: $ids) {\n edges {\n node {\n id\n display_label\n label {\n value\n }\n resolved {\n value\n }\n comments {\n count\n edges {\n node_metadata {\n created_at\n created_by {\n display_label\n }\n }\n node {\n id\n display_label\n text {\n value\n }\n }\n }\n }\n ... on CoreArtifactThread {\n storage_id {\n value\n }\n artifact_id {\n value\n }\n line_number {\n value\n }\n }\n ... on CoreObjectThread {\n object_path {\n value\n }\n }\n ... on CoreFileThread {\n file {\n value\n }\n line_number {\n value\n }\n commit {\n value\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreThread: { edges: { node: { __typename?: "CoreArtifactThread" | undefined; id: string | null; display_label: string | null; label: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; line_number: { value: unknown; } | null; } | { __typename?: "CoreChangeThread" | undefined; id: string | null; display_label: string | null; label: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; } | { __typename?: "CoreFileThread" | undefined; id: string | null; display_label: string | null; label: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; file: { value: string | null; } | null; line_number: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename?: "CoreObjectThread" | undefined; id: string | null; display_label: string | null; label: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; object_path: { value: string | null; } | null; } | null; }[]; }; }, { ids?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:fa4cd11e447de59f2fb7da429e4f8132 */ + /** @gql.tada/hash sha256:6fa58b215f63d72685e88a39dc526d05 */ "\n query actions($proposedChangeId: String!) {\n CoreProposedChangeAvailableActions(proposed_change_id: $proposedChangeId) {\n count\n edges {\n node {\n action\n available\n unavailability_reason\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreProposedChangeAvailableActions: { count: number; edges: { node: { action: string; available: boolean; unavailability_reason: string | null; }; }[]; }; }, { proposedChangeId: string; }, void>; - /** @gql.tada/hash sha256:ce8a3c343ef00b73203d243ee036a36e */ + /** @gql.tada/hash sha256:b6196896bc9dfc883149fa5b04db8018 */ "\n mutation ProposedChangeReview($proposedChangeId: String!, $decision: ProposedChangeApprovalDecision!) {\n CoreProposedChangeReview(data: { id: $proposedChangeId, decision: $decision }) {\n ok\n }\n }\n": TadaDocumentNode<{ CoreProposedChangeReview: { ok: boolean | null; } | null; }, { decision: "APPROVE" | "CANCEL_APPROVE" | "CANCEL_REJECT" | "REJECT"; proposedChangeId: string; }, void>; - /** @gql.tada/hash sha256:08c5143a5b72d882a4a784d8693f625d */ + /** @gql.tada/hash sha256:abeac9129b867e6044bbcfaa9347e12f */ "\n mutation CHECK_REPOSITORY_CONNECTIVITY($repositoryId: String!) {\n InfrahubRepositoryConnectivity(data: { id: $repositoryId }) {\n ok\n message\n }\n }\n": TadaDocumentNode<{ InfrahubRepositoryConnectivity: { ok: boolean; message: string; } | null; }, { repositoryId: string; }, void>; - /** @gql.tada/hash sha256:5a4f0a2024c50b846463fdc9af53a7c5 */ + /** @gql.tada/hash sha256:62d842ae8d5b800fd0676c82b9a9ba3e */ "\n query REPOSITORY_GROUP($nodeIds: [ID]) {\n CoreRepositoryGroup(repository__ids: $nodeIds) {\n edges {\n node {\n id\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreRepositoryGroup: { edges: { node: { id: string; } | null; }[]; }; }, { nodeIds?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:cb381e1486adb021844c2b1581e70122 */ + /** @gql.tada/hash sha256:fd17ac56805b3600ccd5f613e48afbfb */ "\n mutation IMPORT_CURRENT_COMMIT($repositoryId: String!) {\n InfrahubRepositoryProcess(data: { id: $repositoryId }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ InfrahubRepositoryProcess: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { repositoryId: string; }, void>; - /** @gql.tada/hash sha256:56a284f2ec1dbf59ebd32a4f7d0a6920 */ + /** @gql.tada/hash sha256:38a7ab3d8a37411bb8b028f657d22870 */ "\n mutation REIMPORT_LAST_COMMIT($repositoryId: String!) {\n InfrahubReadOnlyRepositoryImportLastCommit(data: { id: $repositoryId }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ InfrahubReadOnlyRepositoryImportLastCommit: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { repositoryId: string; }, void>; - /** @gql.tada/hash sha256:408f42b4ebee22dd864196749483360f */ + /** @gql.tada/hash sha256:283f400fe1bf8201eab18644f115969d */ "\n query GET_NUMBER_POOLS($objectKinds: [String]) {\n CoreNumberPool(node__values: $objectKinds) {\n edges {\n node {\n id\n hfid\n display_label\n node {\n id\n value\n }\n node_attribute {\n id\n value\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreNumberPool: { edges: { node: { id: string; hfid: string[] | null; display_label: string | null; node: { id: string | null; value: string | null; } | null; node_attribute: { id: string | null; value: string | null; } | null; } | null; }[]; }; }, { objectKinds?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:7c4cdfae48ba5e61a08298390bc42299 */ + /** @gql.tada/hash sha256:ae690328a1fdecd1f667f1d332bdaa8b */ "\n query GET_POOL_UTILIZATION($poolId: String!) {\n InfrahubResourcePoolUtilization(pool_id: $poolId) {\n edges {\n node {\n id\n display_label\n kind\n weight\n utilization\n utilization_branches\n utilization_default_branch\n }\n }\n count\n utilization\n utilization_branches\n utilization_default_branch\n }\n }\n": TadaDocumentNode<{ InfrahubResourcePoolUtilization: { edges: { node: { id: string; display_label: string; kind: string; weight: unknown; utilization: number; utilization_branches: number; utilization_default_branch: number; }; }[]; count: unknown; utilization: number; utilization_branches: number; utilization_default_branch: number; }; }, { poolId: string; }, void>; - /** @gql.tada/hash sha256:7b571c1dc8253f9a676c2f6c176577e5 */ + /** @gql.tada/hash sha256:289517d403bff526da229f3a9d6e7d96 */ "\n query GET_RESOURCE_POOL_ALLOCATED(\n $poolId: String!\n $resourceId: String!\n $limit: Int!\n $offset: Int!\n ) {\n InfrahubResourcePoolAllocated(\n pool_id: $poolId\n resource_id: $resourceId\n limit: $limit\n offset: $offset\n ) {\n count\n edges {\n node {\n id\n display_label\n kind\n branch\n identifier\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubResourcePoolAllocated: { count: unknown; edges: { node: { id: string; display_label: string; kind: string; branch: string; identifier: string | null; }; }[]; }; }, { offset: number; limit: number; resourceId: string; poolId: string; }, void>; - /** @gql.tada/hash sha256:7c6390750907a96abd02a9a3f96c3762 */ + /** @gql.tada/hash sha256:880bd1390640420451bcbce80e5d1c90 */ "\n mutation DropdownAdd(\n $kind: String!\n $attribute: String!\n $dropdown: String!\n $label: String\n $color: String\n $description: String\n ) {\n SchemaDropdownAdd(\n data: {\n kind: $kind\n attribute: $attribute\n dropdown: $dropdown\n label: $label\n color: $color\n description: $description\n }\n ) {\n ok\n object {\n value\n label\n color\n description\n }\n }\n }\n": TadaDocumentNode<{ SchemaDropdownAdd: { ok: boolean | null; object: { value: string | null; label: string | null; color: string | null; description: string | null; } | null; } | null; }, { description?: string | null | undefined; color?: string | null | undefined; label?: string | null | undefined; dropdown: string; attribute: string; kind: string; }, void>; - /** @gql.tada/hash sha256:b609600b42aa1130d7cc0948938d58a3 */ + /** @gql.tada/hash sha256:61b42fb51d3a1efbfe5fbd6775221480 */ "\n mutation EnumAdd($kind: String!, $attribute: String!, $enum: String!) {\n SchemaEnumAdd(data: { kind: $kind, attribute: $attribute, enum: $enum }) {\n ok\n }\n }\n": TadaDocumentNode<{ SchemaEnumAdd: { ok: boolean | null; } | null; }, { enum: string; attribute: string; kind: string; }, void>; - /** @gql.tada/hash sha256:f3fa014dae7b7214473ab20c467b6eac */ + /** @gql.tada/hash sha256:5d5318563f3de0086e50e694573552ff */ "\n mutation DropdownDelete($kind: String!, $attribute: String!, $dropdown: String!) {\n SchemaDropdownRemove(data: { kind: $kind, attribute: $attribute, dropdown: $dropdown }) {\n ok\n }\n }\n": TadaDocumentNode<{ SchemaDropdownRemove: { ok: boolean | null; } | null; }, { dropdown: string; attribute: string; kind: string; }, void>; - /** @gql.tada/hash sha256:1bc50ce5d2bdb0f4107b4d4f9dd41bc3 */ + /** @gql.tada/hash sha256:3d6486267ee7d92e0ccc38a93befacf0 */ "\n mutation EnumDelete($kind: String!, $attribute: String!, $enum: String!) {\n SchemaEnumRemove(data: { kind: $kind, attribute: $attribute, enum: $enum }) {\n ok\n }\n }\n": TadaDocumentNode<{ SchemaEnumRemove: { ok: boolean | null; } | null; }, { enum: string; attribute: string; kind: string; }, void>; - /** @gql.tada/hash sha256:410c7296c7a43196fc797ebec5540460 */ + /** @gql.tada/hash sha256:07db1707d3617fe9b9ec1d26f0b48029 */ "\n mutation CANCEL_TASK($id: String!) {\n InfrahubTaskCancel(data: { id: $id }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTaskCancel: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { id: string; }, void>; - /** @gql.tada/hash sha256:c07854cd50b3c40da0ca7d68d2646927 */ + /** @gql.tada/hash sha256:9d309b9e14477acfb8080ef87bbec8d1 */ "\n query TASK_DETAILS_CHECK(\n $ids: [String]\n $branch: String\n $workflow: [String]\n $state: [StateType]\n $relatedNodes: [String]\n ) {\n InfrahubTask(\n ids: $ids\n branch: $branch\n workflow: $workflow\n state: $state\n related_node__ids: $relatedNodes\n ) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; }; }, { relatedNodes?: (string | null)[] | null | undefined; state?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; workflow?: (string | null)[] | null | undefined; branch?: string | null | undefined; ids?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:6f4d0c709a3bc01796e4411eb2527834 */ + /** @gql.tada/hash sha256:7edc06c530f75b3564c8dbc336542053 */ "\n query TASKS_BRANCH_STATUS_COUNT($branch: String!) {\n InfrahubTaskBranchStatus(branch: $branch) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubTaskBranchStatus: { count: number; }; }, { branch: string; }, void>; - /** @gql.tada/hash sha256:e73ed3510b77968f19e85a129b0b675e */ + /** @gql.tada/hash sha256:03b3c3c7e919983935d273bba235ea91 */ "\n query TASK_COUNT(\n $search: String\n $branchName: String\n $state: [StateType]\n $relatedNodeIds: [String]\n ) {\n InfrahubTask(\n q: $search\n branch: $branchName\n state: $state\n related_node__ids: $relatedNodeIds\n ) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; }; }, { relatedNodeIds?: (string | null)[] | null | undefined; state?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; branchName?: string | null | undefined; search?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:a851535d1aa59426996aa5256e21d952 */ + /** @gql.tada/hash sha256:9d81dd29bb9e3c894714372c48a6b8a8 */ "\n query GET_TASK_DETAILS(\n $ids: [String]\n $branch: String\n $workflow: [String]\n $relatedNodes: [String]\n ) {\n InfrahubTask(\n ids: $ids\n branch: $branch\n workflow: $workflow\n related_node__ids: $relatedNodes\n ) {\n count\n edges {\n node {\n id\n title\n branch\n related_node\n related_nodes {\n id\n kind\n }\n state\n progress\n created_at\n updated_at\n logs {\n edges {\n node {\n id\n message\n severity\n timestamp\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; edges: { node: { __typename?: "TaskNode" | undefined; id: string; title: string; branch: string | null; related_node: string | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; progress: number | null; created_at: string; updated_at: string; logs: { edges: { node: { id: string | null; message: string; severity: string; timestamp: string; } | null; }[]; } | null; } | { __typename?: "WebhookDeliveryTask" | undefined; id: string; title: string; branch: string | null; related_node: string | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; progress: number | null; created_at: string; updated_at: string; logs: { edges: { node: { id: string | null; message: string; severity: string; timestamp: string; } | null; }[]; } | null; } | null; }[]; }; }, { relatedNodes?: (string | null)[] | null | undefined; workflow?: (string | null)[] | null | undefined; branch?: string | null | undefined; ids?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:a79a8fddefe8c3ef9df3a6ed3fc47a9d */ + /** @gql.tada/hash sha256:db31a5e654405606890c8eb8c1d63fce */ "\n query GET_TASK_DETAILS_TITLE_QUERY($ids: [String!]) {\n InfrahubTask(ids: $ids) {\n count\n edges {\n node {\n id\n title\n state\n available_actions {\n action\n available\n unavailability_reason\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; edges: { node: { __typename?: "TaskNode" | undefined; id: string; title: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; available_actions: { action: "CANCEL" | "RETRY"; available: boolean; unavailability_reason: string | null; }[]; } | { __typename?: "WebhookDeliveryTask" | undefined; id: string; title: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; available_actions: { action: "CANCEL" | "RETRY"; available: boolean; unavailability_reason: string | null; }[]; } | null; }[]; }; }, { ids?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:4aecb0681a80bca2c71a405dc8c9e814 */ + /** @gql.tada/hash sha256:4c4879cfbecaa5fd34005d1483742bae */ "\n query GET_TASK_LIST(\n $offset: Int\n $limit: Int\n $search: String\n $branchName: String\n $state: [StateType]\n $relatedNodeIds: [String]\n ) {\n InfrahubTask(\n offset: $offset\n limit: $limit\n q: $search\n branch: $branchName\n state: $state\n related_node__ids: $relatedNodeIds\n ) {\n count\n edges {\n node {\n id\n branch\n related_nodes {\n id\n kind\n }\n title\n updated_at\n state\n progress\n workflow\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; edges: { node: { __typename?: "TaskNode" | undefined; id: string; branch: string | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; title: string; updated_at: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; progress: number | null; workflow: string | null; } | { __typename?: "WebhookDeliveryTask" | undefined; id: string; branch: string | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; title: string; updated_at: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; progress: number | null; workflow: string | null; } | null; }[]; }; }, { relatedNodeIds?: (string | null)[] | null | undefined; state?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; branchName?: string | null | undefined; search?: string | null | undefined; limit?: number | null | undefined; offset?: number | null | undefined; }, void>; - /** @gql.tada/hash sha256:ab5d879661f89b237a2d90a15605375f */ + /** @gql.tada/hash sha256:7ddacd42d7a82b556b629f90c4cca15d */ "\n query GET_TASKS_HOMEPAGE($limit: Int, $branchName: String!, $states: [StateType]) {\n InfrahubTask(limit: $limit, branch: $branchName, state: $states) {\n count\n edges {\n node {\n id\n branch\n title\n updated_at\n state\n related_nodes {\n id\n kind\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; edges: { node: { __typename?: "TaskNode" | undefined; id: string; branch: string | null; title: string; updated_at: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; } | { __typename?: "WebhookDeliveryTask" | undefined; id: string; branch: string | null; title: string; updated_at: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; } | null; }[]; }; }, { states?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; branchName: string; limit?: number | null | undefined; }, void>; - /** @gql.tada/hash sha256:3c1f7c8db110dea4c17c637712ecee23 */ + /** @gql.tada/hash sha256:fada3831264b5c48d3312638af6ebff1 */ "\n mutation RETRY_TASK($id: String!) {\n InfrahubTaskRetry(data: { id: $id }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTaskRetry: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { id: string; }, void>; - /** @gql.tada/hash sha256:a4d9c5cf51713665497d8d1df86265f5 */ + /** @gql.tada/hash sha256:64673823a73351d1f736b07b3b1ecda0 */ "\n mutation InfrahubAccountTokenCreate($tokenName: String!, $tokenExpirationDate: String) {\n InfrahubAccountTokenCreate(data: { name: $tokenName, expiration: $tokenExpirationDate }) {\n object {\n id\n token {\n value\n }\n }\n ok\n }\n }\n": TadaDocumentNode<{ InfrahubAccountTokenCreate: { object: { id: string; token: { value: string; } | null; } | null; ok: boolean | null; } | null; }, { tokenExpirationDate?: string | null | undefined; tokenName: string; }, void>; - /** @gql.tada/hash sha256:a05b41d309c1ed850c915a076aef079d */ + /** @gql.tada/hash sha256:84dd466ace63d019824e0e7193ccd43d */ "\n query GetAccountProfile {\n AccountProfile {\n id\n display_label\n is_externally_managed\n name {\n value\n }\n label {\n value\n }\n description {\n value\n }\n }\n }\n": TadaDocumentNode<{ AccountProfile: { __typename?: "CoreAccount" | undefined; id: string | null; display_label: string | null; is_externally_managed: boolean; name: { value: string | null; } | null; label: { value: string | null; } | null; description: { value: string | null; } | null; } | null; }, {}, void>; - /** @gql.tada/hash sha256:9a78809262465c570a8c6d9c52d458ef */ + /** @gql.tada/hash sha256:dcf30d8a0afbf68ed697e06736710ca3 */ "\n query InfrahubAccountToken {\n InfrahubAccountToken {\n count\n edges {\n node {\n id\n name\n expiration\n __typename\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubAccountToken: { count: number; edges: { node: { id: string; name: string | null; expiration: string | null; __typename: "AccountTokenNode"; }; }[]; }; }, {}, void>; - /** @gql.tada/hash sha256:7ea558c595803aa3d4180d9ee8f203dd */ + /** @gql.tada/hash sha256:16b48f884dd87e7b53a129df2d13a001 */ "\n mutation UPDATE_ACCOUNT_PASSWORD($password: String!) {\n InfrahubAccountSelfUpdate(data: { password: $password }) {\n ok\n }\n }\n": TadaDocumentNode<{ InfrahubAccountSelfUpdate: { ok: boolean | null; } | null; }, { password: string; }, void>; } diff --git a/frontend/app/src/shared/api/graphql/generated/graphql-env.d.ts b/frontend/app/src/shared/api/graphql/generated/graphql-env.d.ts index 3e376b8e9c7..178bc569e13 100644 --- a/frontend/app/src/shared/api/graphql/generated/graphql-env.d.ts +++ b/frontend/app/src/shared/api/graphql/generated/graphql-env.d.ts @@ -18,7 +18,7 @@ export type introspection_types = { 'ActionAvailabilityEdge': { kind: 'OBJECT'; name: 'ActionAvailabilityEdge'; fields: { 'node': { name: 'node'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ActionAvailability'; ofType: null; }; } }; }; }; 'AnyAttribute': { kind: 'OBJECT'; name: 'AnyAttribute'; fields: { 'id': { name: 'id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'is_default': { name: 'is_default'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_from_profile': { name: 'is_from_profile'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_protected': { name: 'is_protected'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'owner': { name: 'owner'; type: { kind: 'INTERFACE'; name: 'LineageOwner'; ofType: null; } }; 'permissions': { name: 'permissions'; type: { kind: 'OBJECT'; name: 'PermissionType'; ofType: null; } }; 'source': { name: 'source'; type: { kind: 'INTERFACE'; name: 'LineageSource'; ofType: null; } }; 'updated_at': { name: 'updated_at'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'updated_by': { name: 'updated_by'; type: { kind: 'INTERFACE'; name: 'CoreGenericAccount'; ofType: null; } }; 'value': { name: 'value'; type: { kind: 'SCALAR'; name: 'GenericScalar'; ofType: null; } }; }; }; 'ArtifactEvent': { kind: 'OBJECT'; name: 'ArtifactEvent'; fields: { 'account_id': { name: 'account_id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'artifact_definition_id': { name: 'artifact_definition_id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'branch': { name: 'branch'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'checksum': { name: 'checksum'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'checksum_previous': { name: 'checksum_previous'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'event': { name: 'event'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'has_children': { name: 'has_children'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'level': { name: 'level'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'occurred_at': { name: 'occurred_at'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'parent_id': { name: 'parent_id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'primary_node': { name: 'primary_node'; type: { kind: 'OBJECT'; name: 'RelatedNode'; ofType: null; } }; 'related_nodes': { name: 'related_nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'RelatedNode'; ofType: null; }; }; }; } }; 'storage_id': { name: 'storage_id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'storage_id_previous': { name: 'storage_id_previous'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; }; }; - 'AttributeInterface': { kind: 'INTERFACE'; name: 'AttributeInterface'; fields: { 'is_default': { name: 'is_default'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_protected': { name: 'is_protected'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'updated_at': { name: 'updated_at'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; }; possibleTypes: 'AnyAttribute' | 'CheckboxAttribute' | 'Dropdown' | 'IPHost' | 'IPNetwork' | 'JSONAttribute' | 'ListAttribute' | 'MacAddress' | 'NumberAttribute' | 'TextAttribute'; }; + 'AttributeInterface': { kind: 'INTERFACE'; name: 'AttributeInterface'; fields: { 'is_default': { name: 'is_default'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_protected': { name: 'is_protected'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'updated_at': { name: 'updated_at'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; }; possibleTypes: 'AnyAttribute' | 'CheckboxAttribute' | 'Dropdown' | 'IPAddress' | 'IPHost' | 'IPNetwork' | 'JSONAttribute' | 'ListAttribute' | 'MacAddress' | 'NumberAttribute' | 'TextAttribute'; }; 'AvailableActions': { kind: 'OBJECT'; name: 'AvailableActions'; fields: { 'count': { name: 'count'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'edges': { name: 'edges'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ActionAvailabilityEdge'; ofType: null; }; }; }; } }; }; }; 'BigInt': unknown; 'Boolean': unknown; @@ -714,6 +714,7 @@ export type introspection_types = { 'HttpRequest': { kind: 'OBJECT'; name: 'HttpRequest'; fields: { 'headers': { name: 'headers'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GenericScalar'; ofType: null; }; } }; 'url': { name: 'url'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'HttpResponse': { kind: 'OBJECT'; name: 'HttpResponse'; fields: { 'body': { name: 'body'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'latency_ms': { name: 'latency_ms'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'status_code': { name: 'status_code'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; }; }; 'ID': unknown; + 'IPAddress': { kind: 'OBJECT'; name: 'IPAddress'; fields: { 'id': { name: 'id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'is_default': { name: 'is_default'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_from_profile': { name: 'is_from_profile'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_protected': { name: 'is_protected'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'owner': { name: 'owner'; type: { kind: 'INTERFACE'; name: 'LineageOwner'; ofType: null; } }; 'permissions': { name: 'permissions'; type: { kind: 'OBJECT'; name: 'PermissionType'; ofType: null; } }; 'source': { name: 'source'; type: { kind: 'INTERFACE'; name: 'LineageSource'; ofType: null; } }; 'updated_at': { name: 'updated_at'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'updated_by': { name: 'updated_by'; type: { kind: 'INTERFACE'; name: 'CoreGenericAccount'; ofType: null; } }; 'value': { name: 'value'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'version': { name: 'version'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; }; }; 'IPAddressGetNextAvailable': { kind: 'OBJECT'; name: 'IPAddressGetNextAvailable'; fields: { 'address': { name: 'address'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'IPAddressPoolGetResource': { kind: 'OBJECT'; name: 'IPAddressPoolGetResource'; fields: { 'node': { name: 'node'; type: { kind: 'OBJECT'; name: 'PoolAllocatedNode'; ofType: null; } }; 'ok': { name: 'ok'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; }; }; 'IPAddressPoolGetResourceInput': { kind: 'INPUT_OBJECT'; name: 'IPAddressPoolGetResourceInput'; isOneOf: false; inputFields: [{ name: 'address_type'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'data'; type: { kind: 'SCALAR'; name: 'FixedGenericScalar'; ofType: null; }; defaultValue: null }, { name: 'hfid'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'identifier'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'prefix_length'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }]; }; diff --git a/frontend/app/src/shared/api/graphql/graphqlClientApollo.test.ts b/frontend/app/src/shared/api/graphql/graphqlClientApollo.test.ts deleted file mode 100644 index a01fd0f104a..00000000000 --- a/frontend/app/src/shared/api/graphql/graphqlClientApollo.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { ApolloLink, execute, gql, Observable } from "@apollo/client"; -import type { GraphQLFormattedError } from "graphql"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { ERROR_CODES } from "@/shared/api/errors"; -import { PRIORITY_HEADER } from "@/shared/api/priority"; -import { queryClient } from "@/shared/api/rest/client"; - -import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "@/entities/authentication/api/token-storage"; -import { __navigation } from "@/entities/authentication/domain/use-cases/redirect-to-login"; - -import { handleGraphQLAuthError, priorityLink } from "./graphqlClientApollo"; - -describe("handleGraphQLAuthError — TOKEN_EXPIRED retry-then-bail loop", () => { - // Minimal stand-in for Apollo's `Operation`. The handler only touches - // `getContext`/`setContext`, so we don't bring in the full Apollo type - // just to satisfy a structural shape. - function makeOperation() { - let ctx: Record = {}; - return { - getContext: () => ctx, - setContext: (patch: Record) => { - ctx = { ...ctx, ...patch }; - }, - }; - } - - const tokenExpiredError = { - message: "Token expired", - extensions: { code: ERROR_CODES.TOKEN_EXPIRED, http_status: 401, data: {} }, - } satisfies Partial as GraphQLFormattedError; - - let assignSpy: ReturnType; - let fetchQuerySpy: ReturnType; - let originalAssign: typeof __navigation.assign; - - beforeEach(() => { - localStorage.setItem(ACCESS_TOKEN_KEY, "old-token"); - localStorage.setItem(REFRESH_TOKEN_KEY, "old-refresh"); - - // Swap the navigation holder's `assign` so the handler's hard-nav lands - // on a spy rather than actually navigating the test page. Restored in - // afterEach so unrelated tests don't see the stub. - originalAssign = __navigation.assign; - assignSpy = vi.fn(); - __navigation.assign = assignSpy as unknown as typeof __navigation.assign; - - fetchQuerySpy = vi.spyOn(queryClient, "fetchQuery"); - }); - - afterEach(() => { - __navigation.assign = originalAssign; - vi.restoreAllMocks(); - localStorage.clear(); - }); - - it("first TOKEN_EXPIRED refreshes the token and replays with Bearer header", async () => { - // GIVEN a refresh that returns a fresh access token - fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); - const operation = makeOperation(); - // Stand-in for Apollo's `forward` — emits a single empty result so the - // retry observable completes cleanly. - const forward = vi.fn(() => Observable.of({ data: null })); - - // WHEN the handler sees a TOKEN_EXPIRED for the first time on this op - const result = handleGraphQLAuthError({ - graphQLErrors: [tokenExpiredError], - operation, - forward, - } as any); - - // THEN it returns the retry observable - expect(result).toBeInstanceOf(Observable); - - // Drive the observable through to completion so the refresh promise - // and forward subscription get a chance to run. - await new Promise((resolve, reject) => { - (result as Observable).subscribe({ - complete: () => resolve(), - error: (err) => reject(err), - }); - }); - - // AND the replayed operation carries the Bearer-prefixed new token - const headers = (operation.getContext() as { headers?: { authorization?: string } }).headers; - expect(headers?.authorization).toBe("Bearer new-token"); - - expect(fetchQuerySpy).toHaveBeenCalledOnce(); - expect(forward).toHaveBeenCalledOnce(); - // AND the user is NOT bounced — the happy path completes cleanly - expect(assignSpy).not.toHaveBeenCalled(); - }); - - it("replayed result that still carries TOKEN_EXPIRED bails to /login", async () => { - // GIVEN a refresh that succeeds, but the replayed request still - // comes back with TOKEN_EXPIRED (clock skew, malformed refreshed - // token, server-side revoke between refresh and replay). Apollo's - // onError does NOT re-invoke our handler for this result, so the - // bail has to be detected inside `retryWithRefreshedToken` itself. - fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); - const operation = makeOperation(); - const replayedResult = { errors: [tokenExpiredError] }; - const forward = vi.fn(() => Observable.of(replayedResult)); - - // WHEN the handler runs the retry path - const result = handleGraphQLAuthError({ - graphQLErrors: [tokenExpiredError], - operation, - forward, - } as any); - - // THEN the retry observable errors out with the persistence sentinel - await expect( - new Promise((resolve, reject) => { - (result as Observable).subscribe({ - next: () => {}, - complete: () => resolve(), - error: (err) => reject(err), - }); - }) - ).rejects.toThrow(/persisted/i); - - // AND the user is hard-navigated to /login with `?from=…` - expect(assignSpy).toHaveBeenCalledOnce(); - const target = assignSpy.mock.calls[0]?.[0] as string | undefined; - expect(target).toMatch(/^\/login\?from=/); - - // AND the stale credentials were cleared so the next mount won't loop - expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBeNull(); - expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull(); - }); - - it("refresh failure clears tokens and bounces to /login", async () => { - // GIVEN a refresh that rejects (refresh token expired / server revoked) - fetchQuerySpy.mockRejectedValue(new Error("refresh failed")); - const operation = makeOperation(); - const forward = vi.fn(); - - // WHEN the handler runs the retry path - const result = handleGraphQLAuthError({ - graphQLErrors: [tokenExpiredError], - operation, - forward, - } as any); - - // THEN the retry observable errors out - await expect( - new Promise((resolve, reject) => { - (result as Observable).subscribe({ - complete: () => resolve(), - error: (err) => reject(err), - }); - }) - ).rejects.toThrow("refresh failed"); - - // AND the user is bounced to /login instead of being left signed-in - // against a session the server has already disowned. - expect(assignSpy).toHaveBeenCalledOnce(); - expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBeNull(); - expect(forward).not.toHaveBeenCalled(); - }); -}); - -describe("priorityLink — outbound X-Priority header", () => { - // A terminating capture link records the headers priorityLink produced. - function runThroughPriorityLink(context?: Record) { - let captured: Record | undefined; - - const captureLink = new ApolloLink((operation) => { - captured = operation.getContext().headers as Record; - return Observable.of({ data: null }); - }); - - const link = ApolloLink.from([priorityLink, captureLink]); - - return new Promise | undefined>((resolve, reject) => { - execute(link, { query: gql`{ __typename }`, context }).subscribe({ - complete: () => resolve(captured), - error: (err) => reject(err), - }); - }); - } - - it("stamps X-Priority: high when the operation has no context.priority", async () => { - const headers = await runThroughPriorityLink(); - expect(headers?.[PRIORITY_HEADER]).toBe("high"); - }); - - it("stamps X-Priority: low when the operation declares context.priority = low", async () => { - const headers = await runThroughPriorityLink({ priority: "low" }); - expect(headers?.[PRIORITY_HEADER]).toBe("low"); - }); -}); - -describe("retryWithRefreshedToken (via handleGraphQLAuthError) — X-Priority survives 401 replay", () => { - // Drive the real handler. It reads localStorage via getAccessToken, so stub - // it in node mode; the refresh itself is driven through the fetchQuery spy. - const tokenExpiredError = { - message: "Token expired", - extensions: { code: ERROR_CODES.TOKEN_EXPIRED, http_status: 401, data: {} }, - } satisfies Partial as GraphQLFormattedError; - - let fetchQuerySpy: ReturnType; - - beforeEach(() => { - vi.stubGlobal("localStorage", { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - clear: () => {}, - }); - fetchQuerySpy = vi.spyOn(queryClient, "fetchQuery"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - // Mirrors the first-pass state the retry reads: the stamped X-Priority plus - // the now-expired auth header. - function makeOperationWithPriority() { - let ctx: Record = { - headers: { [PRIORITY_HEADER]: "high", authorization: "Bearer old-token" }, - }; - return { - getContext: () => ctx, - setContext: (patch: Record) => { - ctx = { ...ctx, ...patch }; - }, - }; - } - - it("re-carries the original X-Priority after the refresh+replay", async () => { - // GIVEN - fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); - const operation = makeOperationWithPriority(); - const forward = vi.fn(() => Observable.of({ data: null })); - - // WHEN - const result = handleGraphQLAuthError({ - graphQLErrors: [tokenExpiredError], - operation, - forward, - } as any); - - await new Promise((resolve, reject) => { - (result as Observable).subscribe({ - complete: () => resolve(), - error: (err) => reject(err), - }); - }); - - // THEN - const headers = (operation.getContext() as { headers?: Record }).headers; - expect(headers?.[PRIORITY_HEADER]).toBe("high"); - expect(headers?.authorization).toBe("Bearer new-token"); - expect(forward).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/app/src/shared/api/graphql/graphqlClientApollo.tsx b/frontend/app/src/shared/api/graphql/graphqlClientApollo.tsx deleted file mode 100644 index 401fd0b1e8b..00000000000 --- a/frontend/app/src/shared/api/graphql/graphqlClientApollo.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { - ApolloClient, - type DefaultOptions, - type FetchResult, - from, - InMemoryCache, - Observable, -} from "@apollo/client"; -import { setContext } from "@apollo/client/link/context"; -import { onError } from "@apollo/client/link/error"; -import createUploadLink from "apollo-upload-client/createUploadLink.mjs"; -import { toast } from "react-toastify"; - -import { ERROR_CODES, parseCatalogueError } from "@/shared/api/errors"; -import { PRIORITY_HEADER, resolvePriority } from "@/shared/api/priority"; -import { queryClient } from "@/shared/api/rest/client"; -import { ALERT_TYPES, Alert } from "@/shared/components/ui/alert"; -import { CONFIG } from "@/shared/config/config"; - -import { getAccessToken } from "@/entities/authentication/api/token-storage"; -import { redirectToLogin } from "@/entities/authentication/domain/use-cases/redirect-to-login"; -import { refreshAccessTokenQueryOptions } from "@/entities/authentication/ui/queries/refresh-access-token.query"; - -export const defaultOptions: DefaultOptions = { - watchQuery: { - fetchPolicy: "no-cache", - errorPolicy: "all", - }, - query: { - fetchPolicy: "no-cache", - errorPolicy: "all", - }, -}; - -// HTTP link with context to update graphql endpoint (supports file uploads) -const httpLink = createUploadLink({ - uri: (operation: { getContext: () => { branch?: string; date?: Date | null } }) => { - const context = operation.getContext(); - - return CONFIG.GRAPHQL_URL(context?.branch, context?.date); - }, -}); - -// Auth link to add headers -export const authLink = setContext((_, previousContext) => { - const { headers } = previousContext; - - // Get the token from the session storage - const accessToken = getAccessToken(); - - if (!accessToken) { - return { - headers, - }; - } - - return { - headers: { - ...headers, - authorization: `Bearer ${accessToken}`, - }, - }; -}); - -// The backend prioritizes requests by X-Priority under load, so stamp it -// on every operation — otherwise the frontend's traffic falls back to the -// server default and can't be told apart from other clients when it matters. -export const priorityLink = setContext((_, previousContext) => { - const { headers, priority } = previousContext; - - return { - headers: { - ...headers, - [PRIORITY_HEADER]: resolvePriority(priority), - }, - }; -}); - -type ErrorLinkArgs = Parameters[0]>[0]; - -// True iff a forwarded result still carries TOKEN_EXPIRED. Used inside -// `retryWithRefreshedToken` because Apollo's onError link routes results -// from the retried observable directly to the outer observer — it does -// NOT re-invoke `handleGraphQLAuthError`, so a persistent TOKEN_EXPIRED -// would otherwise leak through to the caller as a generic GraphQL error. -function resultHasTokenExpired(result: FetchResult): boolean { - return ( - result.errors?.some( - (e) => parseCatalogueError(e.extensions).code === ERROR_CODES.TOKEN_EXPIRED - ) ?? false - ); -} - -// Error link callback: route each catalogue code to its policy. The -// discriminated union is generated from `schema/error-catalogue.json` — -// regenerate with `pnpm generate:error-bindings`. Exported (not just inlined -// into `onError`) so tests can drive it directly without spinning up an -// Apollo link chain. -export function handleGraphQLAuthError({ - graphQLErrors, - operation, - forward, -}: ErrorLinkArgs): Observable | undefined { - if (!graphQLErrors) return; - - for (const graphQLError of graphQLErrors) { - const parsed = parseCatalogueError(graphQLError.extensions); - - console.error( - `[GraphQL error]: Code: ${parsed.code}, Message: ${graphQLError.message}, ` + - `Location: ${JSON.stringify(graphQLError.locations)}, Path: ${graphQLError.path}` - ); - - switch (parsed.code) { - case ERROR_CODES.TOKEN_EXPIRED: - // The retry loop is bounded by construction: Apollo's onError - // does not re-invoke this handler for results from the retried - // observable, so we get exactly one refresh+replay attempt per - // operation. Persistence is caught inside `retryWithRefreshedToken`. - return retryWithRefreshedToken(operation, forward); - - case ERROR_CODES.AUTHENTICATION_REQUIRED: - redirectToLogin(); - return; - - case ERROR_CODES.PERMISSION_DENIED: - // Silent — 403s are handled by route-level guards, not toasts. - // `continue` (not `return`) so any sibling errors in the same - // response still reach their handlers. - continue; - - case ERROR_CODES.UNDEFINED_ERROR: - // Catalogue gap: the backend returned a code we don't recognise. - // In dev builds, surface this loudly so engineers see it without - // having to dig through devtools — a console.warn pointing at - // where to register the code, plus a prefix on the toast so the - // miss is visible during manual testing. Prod stays silent - // (just the generic toast) to avoid leaking implementation noise. - if (import.meta.env.DEV) { - console.error( - "[catalogue gap] Unmatched error code surfaced as UNDEFINED_ERROR. " + - "Register it in backend/infrahub/errors/catalogue.py, regenerate " + - "the schema, and run `pnpm generate:error-bindings`.", - { message: graphQLError.message, extensions: graphQLError.extensions } - ); - notifyUser(graphQLError.message, operation); - return; - } - notifyUser(graphQLError.message, operation); - return; - - default: - notifyUser(graphQLError.message, operation); - } - } - - return; -} - -export const errorLink = onError(handleGraphQLAuthError); - -// Helper: refresh the access token and replay the operation. Lifted from -// the previous inline Observable block in errorLink; the only behaviour -// change is that a refresh resolving without an access_token now errors -// the observer instead of leaving it pending (the old code dropped the -// no-token branch silently and the request hung forever). -function retryWithRefreshedToken( - operation: Parameters[0]>[0]["operation"], - forward: Parameters[0]>[0]["forward"] -): Observable { - return new Observable((observer) => { - const oldHeaders = operation.getContext().headers; - - queryClient - .fetchQuery(refreshAccessTokenQueryOptions()) - .then((newToken) => { - if (!newToken?.access_token) { - // Refresh resolved but the server returned no token — treat it - // like a refresh failure: clear stale credentials and bounce to - // /login, otherwise the user is left signed-in against tokens - // the server has already disowned. - redirectToLogin(); - observer.error(new Error("Token refresh returned no access_token")); - return; - } - - operation.setContext({ - headers: { - ...oldHeaders, - authorization: `Bearer ${newToken.access_token}`, - }, - }); - - // Retry the failed request. Inspect the replayed result for a - // repeated TOKEN_EXPIRED — Apollo will not re-enter our handler - // for results that come back from this `forward` call, so this - // is the only place we can detect a persistent expiry (clock - // skew, malformed refreshed token, server-side revoke) and bail - // to /login instead of leaking the error to the caller. - forward(operation).subscribe({ - next: (result) => { - if (resultHasTokenExpired(result)) { - redirectToLogin(); - observer.error(new Error("TOKEN_EXPIRED persisted after refresh")); - return; - } - observer.next(result); - }, - error: observer.error.bind(observer), - complete: observer.complete.bind(observer), - }); - }) - .catch((err) => { - // Refresh itself failed (refresh token expired, network error, - // server-side revoke). Without this branch the caller saw a - // network error, kept the stale credentials in localStorage, - // and every subsequent query hit the same wall — the user was - // effectively stuck until they cleared storage by hand. Bounce - // to /login so they can re-authenticate. - redirectToLogin(); - observer.error(err); - }); - }); -} - -// Helper: surface an error to the user. Calls operation.context's -// processErrorMessage if present (caller-specific override), else toasts. -function notifyUser( - message: string | undefined, - operation: Parameters[0]>[0]["operation"] -) { - if (!message) return; - - const { processErrorMessage } = operation.getContext(); - - if (processErrorMessage) { - processErrorMessage(message); - return; - } - - toast(, { - toastId: "alert-error", - }); -} - -const graphqlClient = new ApolloClient({ - link: from([errorLink, authLink, priorityLink, httpLink]), - cache: new InMemoryCache(), - defaultOptions, - // Apollo is a transport-only layer here: queries run imperatively via - // graphqlClient.query (no Apollo hooks/cache) and are fronted by TanStack - // Query, which owns caching and request deduplication by queryKey. Disable - // Apollo's own in-flight dedup so TanStack is the single dedup authority. - queryDeduplication: false, -}); - -export default graphqlClient; diff --git a/frontend/app/src/shared/api/graphql/types.ts b/frontend/app/src/shared/api/graphql/types.ts new file mode 100644 index 00000000000..3b19f598536 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/types.ts @@ -0,0 +1,13 @@ +import type { CombinedError } from "@urql/core"; + +export interface GraphQLRequestContext { + branch?: string | null; + date?: Date | null; + processErrorMessage?: (message: string) => void; +} + +export interface GraphQLResult { + data: TData; + error?: CombinedError; + errors?: Array<{ message: string }>; +} diff --git a/frontend/app/src/shared/api/rest/client.ts b/frontend/app/src/shared/api/rest/client.ts index af2fc944121..d907b96733b 100644 --- a/frontend/app/src/shared/api/rest/client.ts +++ b/frontend/app/src/shared/api/rest/client.ts @@ -58,13 +58,6 @@ export const authMiddleware: Middleware = { try { const newToken = await queryClient.fetchQuery(refreshAccessTokenQueryOptions()); - if (!newToken?.access_token) { - // Refresh resolved but server returned no token — treat as failure - // and bounce to /login, matching the Apollo errorLink behaviour. - redirectToLogin(); - return response; - } - clonedRequest.headers.set("Authorization", `Bearer ${newToken.access_token}`); return fetch(clonedRequest); } catch (error) { diff --git a/frontend/app/src/shared/api/rest/types.generated.ts b/frontend/app/src/shared/api/rest/types.generated.ts index b9af9e9c7e7..8e610c2751a 100644 --- a/frontend/app/src/shared/api/rest/types.generated.ts +++ b/frontend/app/src/shared/api/rest/types.generated.ts @@ -873,7 +873,7 @@ export interface components { * @description Defines the type of the attribute. (enum property replaced by openapi-typescript) * @enum {string} */ - kind: "Any" | "Bandwidth" | "Boolean" | "Checkbox" | "Color" | "DateTime" | "Dropdown" | "Email" | "File" | "HashedPassword" | "ID" | "IPHost" | "IPNetwork" | "JSON" | "MacAddress" | "Password" | "URL"; + kind: "Any" | "Bandwidth" | "Boolean" | "Checkbox" | "Color" | "DateTime" | "Dropdown" | "Email" | "File" | "HashedPassword" | "ID" | "IPAddress" | "IPHost" | "IPNetwork" | "JSON" | "MacAddress" | "Password" | "URL"; /** * Enum * @description Define a list of valid values for the attribute. @@ -995,7 +995,7 @@ export interface components { * @description Defines the type of the attribute. (enum property replaced by openapi-typescript) * @enum {string} */ - kind: "Any" | "Bandwidth" | "Boolean" | "Checkbox" | "Color" | "DateTime" | "Dropdown" | "Email" | "File" | "HashedPassword" | "ID" | "IPHost" | "IPNetwork" | "JSON" | "MacAddress" | "Password" | "URL"; + kind: "Any" | "Bandwidth" | "Boolean" | "Checkbox" | "Color" | "DateTime" | "Dropdown" | "Email" | "File" | "HashedPassword" | "ID" | "IPAddress" | "IPHost" | "IPNetwork" | "JSON" | "MacAddress" | "Password" | "URL"; /** * Enum * @description Define a list of valid values for the attribute. diff --git a/frontend/app/src/shared/components/form/dynamic-form.tsx b/frontend/app/src/shared/components/form/dynamic-form.tsx index e534eed1a5f..142fcb3f9d3 100644 --- a/frontend/app/src/shared/components/form/dynamic-form.tsx +++ b/frontend/app/src/shared/components/form/dynamic-form.tsx @@ -109,6 +109,7 @@ export const DynamicField = (props: DynamicFieldProps) => { case ATTRIBUTE_KIND.ID: case ATTRIBUTE_KIND.IP_HOST: case ATTRIBUTE_KIND.IP_NETWORK: + case ATTRIBUTE_KIND.IP_ADDRESS: case ATTRIBUTE_KIND.MAC_ADDRESS: case ATTRIBUTE_KIND.TEXT: case ATTRIBUTE_KIND.URL: { diff --git a/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx new file mode 100644 index 00000000000..026c4476b36 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { TestForm } from "../../../../../tests/components/form.story"; +import { render } from "../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../tests/fake/schema"; +import RelationshipManyField from "./relationships/relationship-many.field"; + +// Capture the props handed to the input so we can assert the common_parent wiring +// without driving the whole combobox/query stack. +let lastFilterQuery: unknown; +let lastAddNewInitialObject: unknown; +let lastEnforceOnIdSearch: unknown; +vi.mock("@/shared/components/inputs/relationship-many", () => ({ + RelationshipManyInput: (props: { + filterQuery?: unknown; + addNewInitialObject?: unknown; + enforceFilterQueryOnIdSearch?: unknown; + }) => { + lastFilterQuery = props.filterQuery; + lastAddNewInitialObject = props.addNewInitialObject; + lastEnforceOnIdSearch = props.enforceFilterQueryOnIdSearch; + return ; + }, +})); + +describe("RelationshipManyField - common_parent filtering", () => { + afterEach(() => { + lastFilterQuery = undefined; + lastAddNewInitialObject = undefined; + lastEnforceOnIdSearch = undefined; + vi.clearAllMocks(); + }); + + test("filters the peer options by the common_parent chosen in a sibling field", async () => { + // GIVEN a relationship declaring common_parent: device, with the sibling device picked + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + common_parent: "device", + }); + const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, + }; + + // WHEN the field renders with that sibling value seeded into the form + await render( + + + + ); + + // THEN the input receives a single-hop filter on the chosen parent; the UUID-search override + // is closed, and "Add new" is pre-filled with that parent so a created peer stays valid. + await expect.poll(() => lastFilterQuery).toEqual({ device__ids: ["dev-1"] }); + expect(lastEnforceOnIdSearch).toBe(true); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("passes no filter when the sibling common_parent field is empty", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + }); + + test("passes no filter when the schema declares no common_parent", async () => { + const relationship = generateRelationshipSchema({ name: "tags", peer: "TestProfile" }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx new file mode 100644 index 00000000000..1e1eeba3813 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { store } from "@/shared/stores"; + +import { genericSchemasAtom, nodeSchemasAtom } from "@/entities/schema/stores/schema.atom"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { + generateGenericSchema, + generateNodeSchema, + generateRelationshipSchema, +} from "../../../../../../tests/fake/schema"; +import { GenericRelationshipField } from "./generic-relationship.field"; + +// Capture the props handed to the peer picker. +let lastParent: unknown; +let lastAddNewInitialObject: unknown; +vi.mock("@/shared/components/inputs/relationship-one", () => ({ + RelationshipInput: (props: { parent?: unknown; addNewInitialObject?: unknown }) => { + lastParent = props.parent; + lastAddNewInitialObject = props.addNewInitialObject; + return ; + }, +})); + +// A generic peer with a single concrete implementation (auto-selected). The concrete kind has a +// Parent relationship named "device", so the manual picker would show by default. +const concretePeer = generateNodeSchema({ + kind: "TestProfileOne", + name: "ProfileOne", + relationships: [ + generateRelationshipSchema({ + name: "device", + peer: "TestDevice", + kind: "Parent", + cardinality: "one", + optional: false, + }), + ], +}); + +const genericPeer = generateGenericSchema({ + kind: "TestGenericProfile", + name: "GenericProfile", + relationships: [], + used_by: ["TestProfileOne"], +}); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("GenericRelationshipField - common_parent", () => { + beforeEach(() => { + store.set(nodeSchemasAtom, [concretePeer]); + store.set(genericSchemasAtom, [genericPeer]); + }); + afterEach(() => { + lastParent = undefined; + lastAddNewInitialObject = undefined; + vi.clearAllMocks(); + }); + + test("hides the manual parent picker and filters by the sibling when common_parent is set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestGenericProfile", + cardinality: "one", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastParent).toEqual({ name: "device", value: "dev-1" }); + expect(document.querySelectorAll('[data-testid="rel-input"]')).toHaveLength(1); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("shows the manual parent picker when common_parent is not set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestGenericProfile", + cardinality: "one", + }); + + await render( + + + + ); + + // Manual parent picker present in addition to the peer picker → two inputs. + await expect.poll(() => document.querySelectorAll('[data-testid="rel-input"]').length).toBe(2); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx index f4a9aa813d8..6fc933c6f48 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx @@ -24,6 +24,8 @@ import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; import { useDefaultParent } from "@/entities/nodes/relationships/ui/queries/get-default-parent.query"; import { useSchema } from "@/entities/schema/ui/hooks/useSchema"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + interface GenericOption extends Node { id: string; display_label: string; @@ -57,6 +59,10 @@ export const GenericRelationshipField = ({ ); const parentRelationship = selectedGeneric?.id && getParentRelationship(selectedGeneric.id); + const commonParent = useCommonParentFilter(relationship, name); + // When common_parent drives the filter from a sibling field, the manual "Parent" picker + // is redundant — hide it and source the peer filter from the sibling value instead. + const showManualParent = !commonParent.isActive && !!parentRelationship; const { data: defaultParent } = useDefaultParent({ defaultValue, @@ -156,7 +162,7 @@ export const GenericRelationshipField = ({ setSelectedGeneric={handleKindChange} /> - {parentRelationship && ( + {showManualParent && parentRelationship && ( diff --git a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx new file mode 100644 index 00000000000..db50b084221 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { store } from "@/shared/stores"; + +import { nodeSchemasAtom } from "@/entities/schema/stores/schema.atom"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { + generateNodeSchema, + generateRelationshipSchema, +} from "../../../../../../tests/fake/schema"; +import { NodeRelationshipField } from "./regular-relationship.field"; + +// Capture the props handed to the peer picker. +let lastParent: unknown; +let lastAddNewInitialObject: unknown; +vi.mock("@/shared/components/inputs/relationship-one", () => ({ + RelationshipInput: (props: { parent?: unknown; addNewInitialObject?: unknown }) => { + lastParent = props.parent; + lastAddNewInitialObject = props.addNewInitialObject; + return ; + }, +})); + +// Peer has a Parent relationship named "device", so the manual picker would show by default. +const peerSchema = generateNodeSchema({ + kind: "TestProfile", + name: "Profile", + relationships: [ + generateRelationshipSchema({ + name: "device", + peer: "TestDevice", + kind: "Parent", + cardinality: "one", + optional: false, + }), + ], +}); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("NodeRelationshipField - common_parent", () => { + beforeEach(() => { + store.set(nodeSchemasAtom, [peerSchema]); + }); + afterEach(() => { + lastParent = undefined; + lastAddNewInitialObject = undefined; + vi.clearAllMocks(); + }); + + test("hides the manual parent picker and filters by the sibling when common_parent is set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + cardinality: "one", + common_parent: "device", + }); + + await render( + + + + ); + + // Only the peer picker renders (manual parent picker hidden), filtered by the sibling, + // with "Add new" pre-filled so a created peer stays valid. + await expect.poll(() => lastParent).toEqual({ name: "device", value: "dev-1" }); + expect(document.querySelectorAll('[data-testid="rel-input"]')).toHaveLength(1); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("shows the manual parent picker when common_parent is not set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + cardinality: "one", + }); + + await render( + + + + ); + + // Manual parent picker present in addition to the peer picker → two inputs. + await expect.poll(() => document.querySelectorAll('[data-testid="rel-input"]').length).toBe(2); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx index 20365282f98..f735008e411 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx @@ -17,6 +17,8 @@ import { FormField, FormInput, FormMessage } from "@/shared/components/ui/form"; import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; import { useDefaultParent } from "@/entities/nodes/relationships/ui/queries/get-default-parent.query"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RegularRelationshipFieldProps extends DynamicRelationshipFieldProps { parentDisabled?: boolean; defaultParent?: Node | null; @@ -39,6 +41,10 @@ export const NodeRelationshipField = ({ ...props }: RegularRelationshipFieldProps) => { const parentRelationship = getParentRelationship(relationship.peer); + const commonParent = useCommonParentFilter(relationship, name); + // When common_parent drives the filter from a sibling field, the manual "Parent" picker + // is redundant — hide it and source the peer filter from the sibling value instead. + const showManualParent = !commonParent.isActive && !!parentRelationship; const { data: defaultParent } = useDefaultParent({ defaultValue, @@ -61,7 +67,7 @@ export const NodeRelationshipField = ({ return (
- {parentRelationship && ( + {showManualParent && ( )} - {parentRelationship && ( + {showManualParent && ( @@ -143,7 +149,12 @@ export const NodeRelationshipField = ({ value={value} onChange={onChange} peer={peer} - parent={{ name: parentRelationship?.name, value: selectedParent?.id }} + parent={ + commonParent.isActive + ? commonParent.parent + : { name: parentRelationship?.name, value: selectedParent?.id } + } + addNewInitialObject={commonParent.addNewInitialObject} /> diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx new file mode 100644 index 00000000000..a1ba7c1f030 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../../tests/fake/schema"; +import RelationshipHierarchicalField from "./relationship-hierarchical.field"; + +// Capture the props handed to the hierarchical inputs. +let lastFilterQuery: unknown; +let lastHideExplore: unknown; +vi.mock("@/entities/nodes/relationships/ui/relationship-hierarchical-input", () => ({ + RelationshipHierarchicalInput: (props: { filterQuery?: unknown; hideExplore?: unknown }) => { + lastFilterQuery = props.filterQuery; + lastHideExplore = props.hideExplore; + return ; + }, + RelationshipHierarchicalManyInput: (props: { filterQuery?: unknown; hideExplore?: unknown }) => { + lastFilterQuery = props.filterQuery; + lastHideExplore = props.hideExplore; + return ; + }, +})); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("RelationshipHierarchicalField - common_parent", () => { + afterEach(() => { + lastFilterQuery = undefined; + lastHideExplore = undefined; + vi.clearAllMocks(); + }); + + test("filters by the sibling common_parent value", async () => { + const relationship = generateRelationshipSchema({ + name: "children", + peer: "TestNode", + cardinality: "one", + hierarchical: "TestNode", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toEqual({ device__ids: ["dev-1"] }); + // The tree explorer can't honor the filter, so it is dropped when common_parent applies. + expect(lastHideExplore).toBe(true); + }); + + test("passes no filter when the schema declares no common_parent", async () => { + const relationship = generateRelationshipSchema({ + name: "children", + peer: "TestNode", + cardinality: "one", + hierarchical: "TestNode", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + expect(lastHideExplore).toBe(false); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx index 1b2fc558dc7..f34d73ab038 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx @@ -18,6 +18,8 @@ import { RelationshipHierarchicalManyInput, } from "@/entities/nodes/relationships/ui/relationship-hierarchical-input"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RelationshipHierarchicalFieldProps extends Omit {} @@ -33,6 +35,8 @@ export default function RelationshipHierarchicalField({ shouldUnregister, pool, }: RelationshipHierarchicalFieldProps) { + const commonParent = useCommonParentFilter(relationship, name); + return ( ) : ( )} diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx index 061366165f7..66c59612ae3 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx @@ -12,6 +12,8 @@ import { classNames } from "@/shared/utils/common"; import type { NodeCore } from "@/entities/nodes/object/domain/model/node"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RelationshipManyInputProps extends DynamicRelationshipFieldProps {} export default function RelationshipManyField({ @@ -28,6 +30,8 @@ export default function RelationshipManyField({ filterQuery, ...props }: RelationshipManyInputProps) { + const commonParent = useCommonParentFilter(relationship, name); + return ( :last-child:focus]:border-red-500 has-[>:last-child:focus]:ring-red-500/25" )} peer={relationship.peer} - filterQuery={filterQuery} + filterQuery={ + commonParent.filterQuery + ? { ...filterQuery, ...commonParent.filterQuery } + : filterQuery + } + enforceFilterQueryOnIdSearch={commonParent.isActive} + addNewInitialObject={commonParent.addNewInitialObject} value={fieldData.value as NodeCore[] | null} onChange={(newValue) => { field.onChange( diff --git a/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx new file mode 100644 index 00000000000..a38ca145d59 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx @@ -0,0 +1,112 @@ +import { useFormContext, useWatch } from "react-hook-form"; +import { describe, expect, test } from "vitest"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../../tests/fake/schema"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + +const Probe = ({ commonParent }: { commonParent?: string | null }) => { + const relationship = generateRelationshipSchema({ common_parent: commonParent ?? null }); + const result = useCommonParentFilter(relationship, "dependent"); + return
{JSON.stringify(result)}
; +}; + +const parentValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "atl1-edge", __typename: "InfraDevice" }, +}; + +describe("useCommonParentFilter", () => { + test("is inactive when the relationship declares no common_parent", async () => { + const component = await render( + + + + ); + + await expect + .element(component.getByTestId("result")) + .toHaveTextContent(JSON.stringify({ isActive: false })); + }); + + test("returns no filter while the sibling field is empty", async () => { + const component = await render( + + + + ); + + await expect + .element(component.getByTestId("result")) + .toHaveTextContent(JSON.stringify({ isActive: true, parent: { name: "device" } })); + }); + + test("builds the single-hop filter from the picked sibling parent", async () => { + const component = await render( + + + + ); + + await expect.element(component.getByTestId("result")).toHaveTextContent( + JSON.stringify({ + isActive: true, + filterQuery: { device__ids: ["dev-1"] }, + parent: { name: "device", value: "dev-1" }, + addNewInitialObject: { + device: { node: { id: "dev-1", display_label: "atl1-edge", __typename: "InfraDevice" } }, + }, + }) + ); + }); +}); + +// Harness that reads the dependent field value and lets the test change the parent. +const ClearHarness = () => { + const relationship = generateRelationshipSchema({ name: "profile", common_parent: "device" }); + useCommonParentFilter(relationship, "profile"); + const form = useFormContext(); + const dependent = useWatch({ name: "profile" }); + + return ( +
+
{JSON.stringify(dependent)}
+ +
+ ); +}; + +describe("useCommonParentFilter - clears the selection on parent change", () => { + test("keeps the pre-filled selection on mount but clears it when the parent changes", async () => { + const selected = { + source: { type: "user" as const }, + value: { id: "profile-1", display_label: "p1-alpha-dc1", __typename: "TestProfile" }, + }; + + const component = await render( + + + + ); + + // Preserved on mount. + await expect.element(component.getByTestId("dependent")).toHaveTextContent("profile-1"); + + // Changing the parent clears the now out-of-filter selection. + await component.getByRole("button", { name: "change parent" }).click(); + await expect + .element(component.getByTestId("dependent")) + .toHaveTextContent(JSON.stringify({ source: null, value: null })); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts new file mode 100644 index 00000000000..4b4f6b020b8 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; + +import { DEFAULT_FORM_FIELD_VALUE } from "@/shared/components/form/constants"; +import type { FormRelationshipValue } from "@/shared/components/form/type"; + +import type { NodeFieldsWithMetadata } from "@/entities/nodes/object/domain/model/node"; +import type { RelationshipSchema } from "@/entities/schema/domain/model/schema"; + +// Matches no field, so the useWatch call stays unconditional without subscribing to the whole +// form when the relationship declares no common_parent. +const NO_COMMON_PARENT = "__no_common_parent__"; + +export interface CommonParentFilter { + isActive: boolean; + // Filter for the record-shape consumers (many / hierarchical). + filterQuery?: Record; + // Filter for RelationshipInput's `parent` prop (cardinality one). + parent?: { name: string; value?: string }; + // Seed pre-filling the inline "Add new" form's parent so a created peer stays valid. + addNewInitialObject?: NodeFieldsWithMetadata; +} + +/** + * For a relationship declaring `common_parent: `, filter the peer options to those sharing the + * `` parent picked for the sibling `` field, via a single-hop `__ids` filter. + */ +export const useCommonParentFilter = ( + relationship: RelationshipSchema, + name: string +): CommonParentFilter => { + const commonParent = relationship.common_parent ?? undefined; + const watched = useWatch({ name: commonParent ?? NO_COMMON_PARENT }) as + | FormRelationshipValue + | undefined; + const form = useFormContext(); + + const value = watched?.value; + const parentNode = value && !Array.isArray(value) && "id" in value ? value : undefined; + const chosenParentId = parentNode?.id; + + // A peer picked under one parent no longer satisfies the constraint once the parent changes, so + // clear it — but skip the first observed value so a pre-filled (edit) selection survives mount. + const previousParentId = useRef(chosenParentId); + const isFirstRun = useRef(true); + useEffect(() => { + if (!commonParent) return; + if (isFirstRun.current) { + isFirstRun.current = false; + previousParentId.current = chosenParentId; + return; + } + if (previousParentId.current !== chosenParentId) { + previousParentId.current = chosenParentId; + form.setValue(name, DEFAULT_FORM_FIELD_VALUE, { shouldDirty: true }); + } + }, [chosenParentId, commonParent, name, form]); + + if (!commonParent) return { isActive: false }; + + return { + isActive: true, + filterQuery: chosenParentId ? { [`${commonParent}__ids`]: [chosenParentId] } : undefined, + parent: { name: commonParent, value: chosenParentId }, + addNewInitialObject: parentNode + ? ({ [commonParent]: { node: parentNode } } as NodeFieldsWithMetadata) + : undefined, + }; +}; diff --git a/frontend/app/src/shared/components/form/utils/getFormFieldFromAttribute.ts b/frontend/app/src/shared/components/form/utils/getFormFieldFromAttribute.ts index 96da29e2910..0d02dcf8018 100644 --- a/frontend/app/src/shared/components/form/utils/getFormFieldFromAttribute.ts +++ b/frontend/app/src/shared/components/form/utils/getFormFieldFromAttribute.ts @@ -30,6 +30,7 @@ import type { NumberAttributeParameters, TextAttributeParameters, } from "@/entities/schema/domain/model/schema"; +import { validateIpAddressAttribute } from "@/entities/schema/domain/rules/validation/validate-ip-address-attribute"; import { validateNumberAttribute } from "@/entities/schema/domain/rules/validation/validate-number-attribute"; import { validateTextAttribute } from "@/entities/schema/domain/rules/validation/validate-text-attribute"; @@ -119,6 +120,15 @@ export const getFormFieldFromAttribute = ({ } } + // IPAddress has no parameters, so this check sits outside the block above + if (attributeKind === ATTRIBUTE_KIND.IP_ADDRESS) { + const validation = validateIpAddressAttribute( + { isRequired: !attributeSchema.optional }, + formFieldValue.value as string | null + ); + return validation.success || validation.error; + } + if (attributeSchema.optional) return true; return isRequired(formFieldValue); }, diff --git a/frontend/app/src/shared/components/inputs/dropdown.test.tsx b/frontend/app/src/shared/components/inputs/dropdown.test.tsx index 9f3d9d4606f..c04aaf56d5c 100644 --- a/frontend/app/src/shared/components/inputs/dropdown.test.tsx +++ b/frontend/app/src/shared/components/inputs/dropdown.test.tsx @@ -1,7 +1,5 @@ -import { ApolloProvider } from "@apollo/client"; import { afterEach, describe, expect, test } from "vitest"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { store } from "@/shared/stores"; import type { AttributeSchema, ModelSchema } from "@/entities/schema/domain/model/schema"; @@ -16,9 +14,6 @@ const items = [ { value: "internal", label: "Internal" }, ]; -const renderDropdown = (ui: React.ReactElement) => - render({ui}); - describe("Dropdown delete button", () => { afterEach(() => { store.set(namespacesAtom, []); @@ -30,7 +25,7 @@ describe("Dropdown delete button", () => { const schema = { kind: "CoreStandardGroup", namespace: "Core" } as ModelSchema; // WHEN the dropdown options are shown - const component = await renderDropdown( + const component = await render( { const schema = { kind: "MyCustomNode", namespace: "Builtin" } as ModelSchema; // WHEN the dropdown options are shown - const component = await renderDropdown( + const component = await render( - render({ui}); - describe("Enum delete button", () => { afterEach(() => { store.set(namespacesAtom, []); @@ -26,7 +21,7 @@ describe("Enum delete button", () => { const schema = { kind: "CoreStandardGroup", namespace: "Core" } as ModelSchema; // WHEN the enum options are shown - const component = await renderEnum( + const component = await render( { const schema = { kind: "MyCustomNode", namespace: "Builtin" } as ModelSchema; // WHEN the enum options are shown - const component = await renderEnum( + const component = await render( | null; filterQuery?: Record; + enforceFilterQueryOnIdSearch?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; ref?: React.Ref; } @@ -30,6 +32,8 @@ export function RelationshipManyInput({ value, onChange, filterQuery, + enforceFilterQueryOnIdSearch, + addNewInitialObject, ref, ...props }: RelationshipManyInputProps) { @@ -90,8 +94,13 @@ export function RelationshipManyInput({ onSelect={handleSelect} filterItem={(node) => !value?.some((v) => v.id === node.id)} filterQuery={filterQuery} + enforceFilterQueryOnIdSearch={enforceFilterQueryOnIdSearch} + /> + - ); diff --git a/frontend/app/src/shared/components/inputs/relationship-one.tsx b/frontend/app/src/shared/components/inputs/relationship-one.tsx index 4d2203a051c..9339194f9ba 100644 --- a/frontend/app/src/shared/components/inputs/relationship-one.tsx +++ b/frontend/app/src/shared/components/inputs/relationship-one.tsx @@ -18,6 +18,7 @@ import { useDebounce } from "@/shared/hooks/useDebounce"; import { classNames } from "@/shared/utils/common"; import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; +import type { NodeFieldsWithMetadata } from "@/entities/nodes/object/domain/model/node"; import { getNodeLabel } from "@/entities/nodes/object/domain/rules/get-node-label"; import { AddRelationshipAction } from "@/entities/nodes/relationships/ui/add-relationship-action"; import { useRelationships } from "@/entities/nodes/relationships/ui/queries/get-relationships.query"; @@ -29,6 +30,7 @@ export interface RelationshipInputProps extends Omit; parent?: { name?: string; value?: string }; + addNewInitialObject?: NodeFieldsWithMetadata; ref?: React.Ref>; } @@ -39,6 +41,7 @@ export const RelationshipInput = ({ options, peer, parent, + addNewInitialObject, ref, ...props }: RelationshipInputProps) => { @@ -143,6 +146,7 @@ export const RelationshipInput = ({ {!options && ( { onChange(value); setOpen(false); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 20e829b9ff4..c1c8cc2d928 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -59,9 +59,6 @@ importers: app: dependencies: - '@apollo/client': - specifier: 3.13.8 - version: 3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@codemirror/commands': specifier: ^6.10.4 version: 6.10.4 @@ -137,12 +134,15 @@ importers: '@uiw/react-color': specifier: ^2.10.3 version: 2.10.3(@babel/runtime@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@urql/core': + specifier: ^6.0.3 + version: 6.0.3(graphql@16.14.2) + '@urql/exchange-auth': + specifier: ^3.0.0 + version: 3.0.0(@urql/core@6.0.3(graphql@16.14.2)) '@xyflow/react': specifier: ^12.11.1 version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - apollo-upload-client: - specifier: 18.0.1 - version: 18.0.1(@apollo/client@3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(graphql@16.14.2) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -300,9 +300,6 @@ importers: '@tailwindcss/vite': specifier: 'catalog:' version: 4.3.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@types/apollo-upload-client': - specifier: 18.0.1 - version: 18.0.1(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/dagre': specifier: ^0.7.54 version: 0.7.54 @@ -650,24 +647,6 @@ packages: '@apm-js-collab/tracing-hooks@0.13.0': resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} - '@apollo/client@3.13.8': - resolution: {integrity: sha512-YM9lQpm0VfVco4DSyKooHS/fDTiKQcCHfxr7i3iL6a0kP/jNO5+4NFK6vtRDxaYisd5BrwOZHLJpPBnvRVpKPg==} - peerDependencies: - graphql: ^15.0.0 || ^16.0.0 - graphql-ws: ^5.5.5 || ^6.0.3 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc - subscriptions-transport-ws: ^0.9.0 || ^0.11.0 - peerDependenciesMeta: - graphql-ws: - optional: true - react: - optional: true - react-dom: - optional: true - subscriptions-transport-ws: - optional: true - '@ardatan/relay-compiler@13.0.1': resolution: {integrity: sha512-afG3YPwuSA0E5foouZusz5GlXKs74dObv4cuWyLyfKsYFj2r7oGRNB28v18HvwuLSQtQFCi+DpIe0TZkgQDYyg==} peerDependencies: @@ -4528,9 +4507,6 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/apollo-upload-client@18.0.1': - resolution: {integrity: sha512-qumgUkhs9pqJAxlDtzmn3WTrJ9oAHBb6i9A7aR1HQyjLpX9+LRL5V84aErv5ZwcCSR2zEgG8cFsuBVYfZHFSRA==} - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -4666,9 +4642,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/extract-files@13.0.2': - resolution: {integrity: sha512-4sd7uDB0OVZmwH2wD6w7Qlpr2P5Pn8C9IGwnaq9aiiBDD3Lou7CwFjjkJTDYCDsEvk9zxAtmv9TaMg1lt/YJfA==} - '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -4886,6 +4859,14 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@urql/core@6.0.3': + resolution: {integrity: sha512-0F39XWR21+IVLJfsqBvLShvoSzxT6h6wWXwrDhN8j9J+IEVOpQIUNHwpgjsp+2ePzn9aCH+rpDonn3HOAUnn9A==} + + '@urql/exchange-auth@3.0.0': + resolution: {integrity: sha512-tj09xiOR2f1J2h8TE9uZWjRZipCdmDoTewEytOacDQ+0Teo+yIZxm3ppHxolQtiA51OHrGYiNTkMte8HtfvaBw==} + peerDependencies: + '@urql/core': ^6.0.0 + '@vitejs/plugin-react@6.0.4': resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5028,22 +5009,6 @@ packages: resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} engines: {node: '>=16.0.0'} - '@wry/caches@1.0.1': - resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} - engines: {node: '>=8'} - - '@wry/context@0.7.4': - resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} - engines: {node: '>=8'} - - '@wry/equality@0.5.7': - resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} - engines: {node: '>=8'} - - '@wry/trie@0.5.0': - resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} - engines: {node: '>=8'} - '@xyflow/react@12.10.2': resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==} peerDependencies: @@ -5158,13 +5123,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - apollo-upload-client@18.0.1: - resolution: {integrity: sha512-OQvZg1rK05VNI79D658FUmMdoI2oB/KJKb6QGMa2Si25QXOaAvLMBFUEwJct7wf+19U8vk9ILhidBOU1ZWv6QA==} - engines: {node: ^18.15.0 || >=20.4.0} - peerDependencies: - '@apollo/client': ^3.8.0 - graphql: 14 - 16 - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -5979,10 +5937,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extract-files@13.0.0: - resolution: {integrity: sha512-FXD+2Tsr8Iqtm3QZy1Zmwscca7Jx3mMC5Crr+sEP1I303Jy1CYMuYCm7hRTplFNg3XdUavErkxnTzpaqdSoi6g==} - engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -6277,9 +6231,6 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -7147,9 +7098,6 @@ packages: peerDependencies: typescript: ^5.x - optimism@0.18.1: - resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -7684,17 +7632,6 @@ packages: refractor@5.0.0: resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} - rehackt@0.1.0: - resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} - peerDependencies: - '@types/react': '*' - react: '*' - peerDependenciesMeta: - '@types/react': - optional: true - react: - optional: true - rehype-mermaid@3.0.0: resolution: {integrity: sha512-fxrD5E4Fa1WXUjmjNDvLOMT4XB1WaxcfycFIWiYU0yEMQhcTDElc9aDFnbDFRLxG1Cfo1I3mfD5kg4sjlWaB+Q==} peerDependencies: @@ -8017,10 +7954,6 @@ packages: swap-case@3.0.3: resolution: {integrity: sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA==} - symbol-observable@4.0.0: - resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} - engines: {node: '>=0.10'} - sync-fetch@0.6.0: resolution: {integrity: sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ==} engines: {node: '>=18'} @@ -8132,10 +8065,6 @@ packages: resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} engines: {node: '>=6.10'} - ts-invariant@0.10.3: - resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} - engines: {node: '>=8'} - ts-log@3.0.2: resolution: {integrity: sha512-esq6hx2lM66sQV1YcFkIYTqrWWabmqBqobKHyn1CswdI5FgfQhkmiKiRWVGBNlIbdjBxEIkNvMIwLKKPgRYZLQ==} engines: {node: '>=20', npm: '>=10'} @@ -8522,6 +8451,9 @@ packages: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} + wonka@6.3.6: + resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -8604,12 +8536,6 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} - zen-observable-ts@1.2.5: - resolution: {integrity: sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==} - - zen-observable@0.8.15: - resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} - zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -8703,29 +8629,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@apollo/client@3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) - '@wry/caches': 1.0.1 - '@wry/equality': 0.5.7 - '@wry/trie': 0.5.0 - graphql: 16.14.2 - graphql-tag: 2.12.6(graphql@16.14.2) - hoist-non-react-statics: 3.3.2 - optimism: 0.18.1 - prop-types: 15.8.1 - rehackt: 0.1.0(@types/react@19.2.17)(react@19.2.8) - symbol-observable: 4.0.0 - ts-invariant: 0.10.3 - tslib: 2.8.1 - zen-observable-ts: 1.2.5 - optionalDependencies: - graphql-ws: 6.0.8(graphql@16.14.2)(ws@8.21.0) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - transitivePeerDependencies: - - '@types/react' - '@ardatan/relay-compiler@13.0.1(graphql@16.14.2)': dependencies: '@babel/runtime': 7.29.2 @@ -12066,18 +11969,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/apollo-upload-client@18.0.1(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@apollo/client': 3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@types/extract-files': 13.0.2 - graphql: 16.14.2 - transitivePeerDependencies: - - '@types/react' - - graphql-ws - - react - - react-dom - - subscriptions-transport-ws - '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -12245,8 +12136,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/extract-files@13.0.2': {} - '@types/geojson@7946.0.16': {} '@types/hast@3.0.4': @@ -12511,6 +12400,18 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@urql/core@6.0.3(graphql@16.14.2)': + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.14.2) + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + + '@urql/exchange-auth@3.0.0(@urql/core@6.0.3(graphql@16.14.2))': + dependencies: + '@urql/core': 6.0.3(graphql@16.14.2) + wonka: 6.3.6 + '@vitejs/plugin-react@6.0.4(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -12785,22 +12686,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@wry/caches@1.0.1': - dependencies: - tslib: 2.8.1 - - '@wry/context@0.7.4': - dependencies: - tslib: 2.8.1 - - '@wry/equality@0.5.7': - dependencies: - tslib: 2.8.1 - - '@wry/trie@0.5.0': - dependencies: - tslib: 2.8.1 - '@xyflow/react@12.10.2(@types/react@19.2.17)(immer@11.1.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@xyflow/system': 0.0.76 @@ -12943,12 +12828,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - apollo-upload-client@18.0.1(@apollo/client@3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(graphql@16.14.2): - dependencies: - '@apollo/client': 3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - extract-files: 13.0.0 - graphql: 16.14.2 - argparse@2.0.1: {} aria-hidden@1.2.6: @@ -13765,10 +13644,6 @@ snapshots: extend@3.0.2: {} - extract-files@13.0.0: - dependencies: - is-plain-obj: 4.1.0 - fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -14113,10 +13988,6 @@ snapshots: highlightjs-vue@1.0.0: {} - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - html-escaper@2.0.2: {} html-to-image@1.11.13: {} @@ -15101,13 +14972,6 @@ snapshots: typescript: 5.9.3 yargs-parser: 21.1.1 - optimism@0.18.1: - dependencies: - '@wry/caches': 1.0.1 - '@wry/context': 0.7.4 - '@wry/trie': 0.5.0 - tslib: 2.8.1 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -15917,11 +15781,6 @@ snapshots: hastscript: 9.0.1 parse-entities: 4.0.2 - rehackt@0.1.0(@types/react@19.2.17)(react@19.2.8): - optionalDependencies: - '@types/react': 19.2.17 - react: 19.2.8 - rehype-mermaid@3.0.0(playwright@1.60.0): dependencies: '@types/hast': 3.0.4 @@ -16282,8 +16141,6 @@ snapshots: swap-case@3.0.3: {} - symbol-observable@4.0.0: {} - sync-fetch@0.6.0: dependencies: node-fetch: 3.3.2 @@ -16356,10 +16213,6 @@ snapshots: ts-dedent@2.3.0: {} - ts-invariant@0.10.3: - dependencies: - tslib: 2.8.1 - ts-log@3.0.2: {} tsconfig-paths@4.2.0: @@ -16757,6 +16610,8 @@ snapshots: dependencies: string-width: 8.2.2 + wonka@6.3.6: {} + word-wrap@1.2.5: {} wrap-ansi@10.0.0: @@ -16808,12 +16663,6 @@ snapshots: yoga-layout@3.2.1: {} - zen-observable-ts@1.2.5: - dependencies: - zen-observable: 0.8.15 - - zen-observable@0.8.15: {} - zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pyproject.toml b/pyproject.toml index cb89e29ce1f..dc02f7c055c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1270,7 +1270,6 @@ no-matching-overload = "ignore" not-subscriptable = "ignore" possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" -unresolved-import = "ignore" unresolved-reference = "ignore" unsupported-operator = "ignore" @@ -1298,7 +1297,6 @@ include = ["utilities/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -possibly-missing-attribute = "ignore" possibly-missing-submodule = "ignore" unresolved-import = "ignore" @@ -1315,8 +1313,6 @@ invalid-assignment = "ignore" missing-argument = "ignore" no-matching-overload = "ignore" not-subscriptable = "ignore" -possibly-missing-attribute = "ignore" -too-many-positional-arguments = "ignore" unresolved-attribute = "ignore" unresolved-import = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1336,16 +1332,6 @@ too-many-positional-arguments = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments -[[tool.ty.overrides]] -include = ["backend/infrahub/core/query/standard_node.py"] - -[tool.ty.overrides.rules] -################################################################################################## -# The ignored rules below should be removed once the code has been updated, they are included # -# like this so that we can reactivate them one by one. # -################################################################################################## -type-assertion-failure = "ignore" - [[tool.ty.overrides]] include = ["backend/infrahub/core/**"] @@ -1686,10 +1672,8 @@ include = ["backend/infrahub/git/**"] ################################################################################################## invalid-argument-type = "ignore" invalid-assignment = "ignore" -invalid-await = "ignore" invalid-return-type = "ignore" no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1701,9 +1685,7 @@ include = ["backend/infrahub/database/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -possibly-missing-attribute = "ignore" invalid-argument-type = "ignore" -invalid-assignment = "ignore" invalid-return-type = "ignore" invalid-type-form = "ignore" unresolved-attribute = "ignore" @@ -1717,9 +1699,7 @@ include = ["backend/infrahub/services/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -deprecated = "ignore" invalid-await = "ignore" -unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1730,7 +1710,6 @@ include = ["backend/infrahub/services/adapters/**/nats.py"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -possibly-missing-attribute = "ignore" possibly-missing-submodule = "ignore" [[tool.ty.overrides]] @@ -1743,9 +1722,6 @@ include = ["backend/infrahub/proposed_change/**"] ################################################################################################## invalid-argument-type = "ignore" invalid-assignment = "ignore" -invalid-await = "ignore" -no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1758,9 +1734,6 @@ include = ["backend/infrahub/generators/**"] # like this so that we can reactivate them one by one. # ################################################################################################## invalid-argument-type = "ignore" -invalid-await = "ignore" -no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1775,7 +1748,6 @@ include = ["backend/infrahub/git_credential/**"] invalid-argument-type = "ignore" invalid-assignment = "ignore" no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1789,7 +1761,6 @@ include = ["backend/infrahub/webhook/**"] ################################################################################################## invalid-argument-type = "ignore" no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1801,8 +1772,6 @@ include = ["backend/infrahub/cli/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -invalid-argument-type = "ignore" -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1813,7 +1782,6 @@ include = ["backend/infrahub/telemetry/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1824,9 +1792,7 @@ include = ["backend/infrahub/computed_attribute/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -invalid-argument-type = "ignore" no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1839,7 +1805,6 @@ include = ["backend/infrahub/workers/**"] # like this so that we can reactivate them one by one. # ################################################################################################## invalid-argument-type = "ignore" -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1850,7 +1815,6 @@ include = ["backend/infrahub/trigger/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1861,7 +1825,6 @@ include = ["backend/infrahub/workflows/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1872,7 +1835,6 @@ include = ["backend/infrahub/message_bus/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -call-non-callable = "ignore" call-top-callable = "ignore" invalid-await = "ignore" no-matching-overload = "ignore" @@ -1898,7 +1860,6 @@ include = ["backend/infrahub/artifacts/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1910,7 +1871,6 @@ include = ["backend/infrahub/tasks/**"] # like this so that we can reactivate them one by one. # ################################################################################################## missing-argument = "ignore" -no-matching-overload = "ignore" unknown-argument = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1933,9 +1893,7 @@ include = ["backend/infrahub/profiles/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -invalid-argument-type = "ignore" invalid-assignment = "ignore" -no-matching-overload = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1971,7 +1929,6 @@ include = ["backend/infrahub/actions/**"] # like this so that we can reactivate them one by one. # ################################################################################################## invalid-argument-type = "ignore" -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1982,7 +1939,6 @@ include = ["backend/infrahub/branch/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1993,7 +1949,6 @@ include = ["backend/infrahub/schema/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -2007,10 +1962,6 @@ include = ["backend/infrahub/*.py"] invalid-argument-type = "ignore" invalid-assignment = "ignore" invalid-method-override = "ignore" -invalid-return-type = "ignore" -no-matching-overload = "ignore" -not-subscriptable = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -2093,7 +2044,6 @@ include = ["backend/infrahub/task_manager/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -invalid-argument-type = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -2112,7 +2062,6 @@ missing-argument = "ignore" no-matching-overload = "ignore" not-subscriptable = "ignore" not-iterable = "ignore" -possibly-missing-attribute = "ignore" unknown-argument = "ignore" unresolved-attribute = "ignore" unsupported-operator = "ignore" @@ -2141,11 +2090,9 @@ include = ["backend/tests/integration/**"] invalid-argument-type = "ignore" invalid-assignment = "ignore" invalid-await = "ignore" -invalid-method-override = "ignore" invalid-return-type = "ignore" no-matching-overload = "ignore" not-subscriptable = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unsupported-operator = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -2163,7 +2110,6 @@ invalid-assignment = "ignore" invalid-return-type = "ignore" no-matching-overload = "ignore" not-iterable = "ignore" -possibly-missing-attribute = "ignore" unknown-argument = "ignore" unresolved-attribute = "ignore" unsupported-operator = "ignore" @@ -2180,7 +2126,6 @@ include = ["backend/tests/integration_docker/**"] invalid-argument-type = "ignore" invalid-assignment = "ignore" invalid-return-type = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" [[tool.ty.overrides]] @@ -2193,7 +2138,6 @@ include = ["backend/tests/scale/**"] ################################################################################################## invalid-assignment = "ignore" not-iterable = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unresolved-import = "ignore" unsupported-operator = "ignore" @@ -2206,8 +2150,6 @@ include = ["backend/tests/fixtures/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unresolved-import = "ignore" invalid-argument-type = "ignore" @@ -2242,9 +2184,7 @@ include = ["backend/tests/query_benchmark/**"] # like this so that we can reactivate them one by one. # ################################################################################################## invalid-return-type = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" -unresolved-import = "ignore" [tool.hatch.build.targets.sdist] include = [ diff --git a/python_sdk b/python_sdk index 681b458cd32..f9e28cfd595 160000 --- a/python_sdk +++ b/python_sdk @@ -1 +1 @@ -Subproject commit 681b458cd324c6eec746bb225135cbb7dd99640e +Subproject commit f9e28cfd5958946759f113fd9fe29422adc8fcea diff --git a/schema/error-catalogue.json b/schema/error-catalogue.json index f12c60019ec..e6619e912d8 100644 --- a/schema/error-catalogue.json +++ b/schema/error-catalogue.json @@ -301,7 +301,7 @@ } }, "MERGE_RECOVERY_REQUIRED": { - "description": "The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover`. Unlike MERGE_IN_PROGRESS this is not retryable.", + "description": "The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover merge`. Unlike MERGE_IN_PROGRESS this is not retryable.", "stability": "evolving", "http_status": 423, "data_schema": { diff --git a/schema/openapi.json b/schema/openapi.json index 831973a74bb..bb15a0e6d8a 100644 --- a/schema/openapi.json +++ b/schema/openapi.json @@ -3059,6 +3059,7 @@ "Bandwidth", "IPHost", "IPNetwork", + "IPAddress", "Boolean", "Checkbox", "JSON", @@ -3335,6 +3336,7 @@ "Bandwidth", "IPHost", "IPNetwork", + "IPAddress", "Boolean", "Checkbox", "JSON", @@ -3802,6 +3804,7 @@ "File": "#/components/schemas/GenericAttributeRead", "HashedPassword": "#/components/schemas/GenericAttributeRead", "ID": "#/components/schemas/GenericAttributeRead", + "IPAddress": "#/components/schemas/GenericAttributeRead", "IPHost": "#/components/schemas/GenericAttributeRead", "IPNetwork": "#/components/schemas/GenericAttributeRead", "JSON": "#/components/schemas/GenericAttributeRead", @@ -4124,6 +4127,7 @@ "File": "#/components/schemas/GenericAttributeWrite", "HashedPassword": "#/components/schemas/GenericAttributeWrite", "ID": "#/components/schemas/GenericAttributeWrite", + "IPAddress": "#/components/schemas/GenericAttributeWrite", "IPHost": "#/components/schemas/GenericAttributeWrite", "IPNetwork": "#/components/schemas/GenericAttributeWrite", "JSON": "#/components/schemas/GenericAttributeWrite", @@ -5299,6 +5303,7 @@ "File": "#/components/schemas/GenericAttributeWrite", "HashedPassword": "#/components/schemas/GenericAttributeWrite", "ID": "#/components/schemas/GenericAttributeWrite", + "IPAddress": "#/components/schemas/GenericAttributeWrite", "IPHost": "#/components/schemas/GenericAttributeWrite", "IPNetwork": "#/components/schemas/GenericAttributeWrite", "JSON": "#/components/schemas/GenericAttributeWrite", @@ -5567,6 +5572,7 @@ "File": "#/components/schemas/GenericAttributeRead", "HashedPassword": "#/components/schemas/GenericAttributeRead", "ID": "#/components/schemas/GenericAttributeRead", + "IPAddress": "#/components/schemas/GenericAttributeRead", "IPHost": "#/components/schemas/GenericAttributeRead", "IPNetwork": "#/components/schemas/GenericAttributeRead", "JSON": "#/components/schemas/GenericAttributeRead", @@ -5910,6 +5916,7 @@ "File": "#/components/schemas/GenericAttributeWrite", "HashedPassword": "#/components/schemas/GenericAttributeWrite", "ID": "#/components/schemas/GenericAttributeWrite", + "IPAddress": "#/components/schemas/GenericAttributeWrite", "IPHost": "#/components/schemas/GenericAttributeWrite", "IPNetwork": "#/components/schemas/GenericAttributeWrite", "JSON": "#/components/schemas/GenericAttributeWrite", @@ -7429,6 +7436,7 @@ "File": "#/components/schemas/GenericAttributeRead", "HashedPassword": "#/components/schemas/GenericAttributeRead", "ID": "#/components/schemas/GenericAttributeRead", + "IPAddress": "#/components/schemas/GenericAttributeRead", "IPHost": "#/components/schemas/GenericAttributeRead", "IPNetwork": "#/components/schemas/GenericAttributeRead", "JSON": "#/components/schemas/GenericAttributeRead", @@ -8702,6 +8710,7 @@ "File": "#/components/schemas/GenericAttributeRead", "HashedPassword": "#/components/schemas/GenericAttributeRead", "ID": "#/components/schemas/GenericAttributeRead", + "IPAddress": "#/components/schemas/GenericAttributeRead", "IPHost": "#/components/schemas/GenericAttributeRead", "IPNetwork": "#/components/schemas/GenericAttributeRead", "JSON": "#/components/schemas/GenericAttributeRead", diff --git a/schema/schema.graphql b/schema/schema.graphql index 4375ac846b6..e2f50cbd512 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -8628,6 +8628,24 @@ type HttpResponse { status_code: Int } +"""Attribute of type IPAddress""" +type IPAddress implements AttributeInterface { + id: String + is_default: Boolean + is_from_profile: Boolean + is_protected: Boolean + owner: LineageOwner + permissions: PermissionType + source: LineageSource + """ + Date/Time when the attribute was last modified by a user or a system task + """ + updated_at: DateTime + updated_by: CoreGenericAccount + value: String + version: Int +} + type IPAddressGetNextAvailable { address: String! } diff --git a/tasks/backend.py b/tasks/backend.py index a711f2c3f7a..3bc76ffd59f 100644 --- a/tasks/backend.py +++ b/tasks/backend.py @@ -550,6 +550,7 @@ def inherit_from(subject: str) -> SchemaAttribute: "Bandwidth": "BANDWIDTH", "IPHost": "IPHOST", "IPNetwork": "IPNETWORK", + "IPAddress": "IPADDRESS", "Boolean": "BOOLEAN", "Checkbox": "CHECKBOX", "List": "LIST", @@ -635,12 +636,47 @@ def _sdk_kind_field( def _write_sdk_generated_init(generated: str) -> None: init_content = ( '# Generated by "invoke backend.generate", do not edit directly\n' - "from . import enums, read, write\n\n" - '__all__ = ["enums", "read", "write"]\n' + "from . import contract, enums, read, write\n\n" + '__all__ = ["contract", "enums", "read", "write"]\n' ) Path(f"{generated}/__init__.py").write_text(init_content, encoding="utf-8") +# Top-level keys `GET /api/schema` returns that have no counterpart on the write root. They are +# read-only rather than unknown, so a raw response body resubmitted to the load endpoint warns +# instead of failing. Pinned against the API response model by a backend test. +_ROOT_READ_ONLY_FIELDS = frozenset({"main", "namespaces", "profiles", "templates"}) + + +def _family_field_name(attribute: "SchemaAttribute | dict[str, str]") -> str: + """Name of a family field, declared either as a SchemaAttribute or as a pre-rendered dict.""" + return attribute["name"] if isinstance(attribute, dict) else attribute.name + + +def _write_sdk_generated_contract(generated: str, read_only_fields: dict[str, frozenset[str]]) -> None: + entries = "" + for class_name, fields in sorted(read_only_fields.items()): + names = ", ".join(f'"{name}"' for name in sorted(fields)) + entries += f' "{class_name}": frozenset({{{names}}}),\n' + content = ( + '# Generated by "invoke backend.generate", do not edit directly\n' + '"""Read-only fields of the write contract, keyed by generated write class name.\n' + "\n" + "A field listed here is one the contract knows at that location but the user may not set:\n" + "a field the read API returns, the bookkeeping a schema dumped from the internal models\n" + "carries, or a field belonging to a sibling variant of a discriminated union. Submitting one\n" + "is reported as a warning and the value is dropped, where an extra field that is not listed\n" + "is an error. Each entry already includes what the class inherits, so a lookup is by class\n" + "name alone.\n" + '"""\n' + "\n" + "READ_ONLY_FIELDS: dict[str, frozenset[str]] = {\n" + f"{entries}" + "}\n" + ) + Path(f"{generated}/contract.py").write_text(content, encoding="utf-8") + + @dataclass(frozen=True) class SdkVariant: """One generated module of the SDK schema family (``write.py`` / ``read.py``).""" @@ -833,6 +869,7 @@ def generate(self) -> None: rendered = rendered.replace("__VARIANT__", variant.suffix) Path(f"{self.generated}/{variant.name}.py").write_text(rendered, encoding="utf-8") + _write_sdk_generated_contract(self.generated, self._read_only_fields()) _write_sdk_generated_init(self.generated) execute_command(context=self.context, command=f'ruff format "{self.generated}"') @@ -1043,6 +1080,98 @@ def _families(self, minimum: "Visibility", suffix: str) -> list[dict[str, Any]]: }, ] + def _families_for_contract(self, minimum: "Visibility") -> list[dict[str, Any]]: + """Every generated write family, at the given field visibility.""" + suffix = "Write" + return self._pre_families(minimum, suffix) + self._families(minimum, suffix) + _sdk_extension_families(suffix) + + @staticmethod + def _declared_names(families: list[dict[str, Any]]) -> dict[str, set[str]]: + """Field names each family declares itself, excluding what it inherits.""" + names: dict[str, set[str]] = {} + for family in families: + fields = {_family_field_name(attribute) for attribute in family["attributes"]} + # A computed field is part of the payload only for the variant that serializes it. + fields |= {computed["name"] for computed in family.get("computed_fields", []) if computed["serialize"]} + names[family["class_name"]] = fields + return names + + @staticmethod + def _inherited_names(families: list[dict[str, Any]], declared: dict[str, set[str]]) -> dict[str, set[str]]: + """Field names each family accepts, following its parent chain.""" + parents = {family["class_name"]: family["parent"] for family in families} + resolved: dict[str, set[str]] = {} + for class_name in declared: + names: set[str] = set() + current: str | None = class_name + while current in declared: + names |= declared[current] + current = parents.get(current) + resolved[class_name] = names + return resolved + + def _value_model_read_only_fields(self, write_names: dict[str, set[str]]) -> dict[str, set[str]]: + """Non-settable fields of the value models, taken from their internal counterparts. + + The parameters, choice, computed-attribute and extension models are declared in this + generator rather than derived from internal.py, so they omit the bookkeeping fields every + internal schema model carries (``id``, ``state``) and each computed-attribute variant omits + the fields of its siblings. All of those names appear in a schema dumped from the internal + models, so they are tolerated with a warning rather than reported as unknown. + """ + from infrahub.core.schema import NodeExtensionSchema, SchemaExtension + from infrahub.core.schema import attribute_parameters as parameters_module + from infrahub.core.schema.computed_attribute import ComputedAttribute + from infrahub.core.schema.dropdown import DropdownChoice + + suffix = "Write" + internal_counterparts: dict[str, Any] = { + f"DropdownChoice{suffix}": DropdownChoice, + f"SchemaExtension{suffix}": SchemaExtension, + f"NodeExtension{suffix}": NodeExtensionSchema, + } + for variant in ("User", "Jinja2", "TransformPython"): + internal_counterparts[f"ComputedAttribute{variant}{suffix}"] = ComputedAttribute + for _, parameters_name in self.attribute_variant_specs: + internal_counterparts[f"{parameters_name}{suffix}"] = getattr(parameters_module, parameters_name) + + return { + class_name: set(internal.model_fields) - write_names.get(class_name, set()) + for class_name, internal in internal_counterparts.items() + } + + def _read_only_fields(self) -> dict[str, frozenset[str]]: + """Field names a generated write class does not accept but a submitted schema may carry. + + Two sources feed the table: a field the read variant of a class declares and the write + variant does not, and a field the internal counterpart of a value model declares and the + generated write model does not. Deriving both here rather than reading the emitted modules + keeps the table in step with the definitions the models themselves come from. + + Each entry is resolved through the parent chain, so a consumer looks a class up by name and + needs to know nothing about how the generated models inherit from each other. + """ + from infrahub.core.constants import Visibility + + write_families = self._families_for_contract(Visibility.WRITE) + write_declared = self._declared_names(write_families) + read_declared = self._declared_names(self._families_for_contract(Visibility.READ)) + + # An entry is kept for every class, including the empty ones, so resolving through the + # parent chain does not stop at a class that declares no read-only field of its own. + declared = { + class_name: fields - write_declared.get(class_name, set()) for class_name, fields in read_declared.items() + } + for class_name, fields in self._value_model_read_only_fields( + write_names=self._inherited_names(families=write_families, declared=write_declared) + ).items(): + declared[class_name] = declared.get(class_name, set()) | fields + + read_only = self._inherited_names(families=write_families, declared=declared) + read_only["InfrahubSchemaWrite"] = set(_ROOT_READ_ONLY_FIELDS) + + return {class_name: frozenset(fields) for class_name, fields in read_only.items() if fields} + def _base_node_family(self, minimum: "Visibility", suffix: str) -> dict[str, Any]: """Base node family shared by node/generic/profile/template. diff --git a/tasks/docs.py b/tasks/docs.py index c041fdf59cd..c245f77af17 100644 --- a/tasks/docs.py +++ b/tasks/docs.py @@ -187,6 +187,7 @@ def _generate_infrahub_cli_documentation(context: Context) -> None: ("infrahub.cli.server", "infrahub server", "infrahub-server"), ("infrahub.cli.dev", "infrahub dev", "infrahub-dev"), ("infrahub.cli.upgrade", "infrahub upgrade", "infrahub-upgrade"), + ("infrahub.cli.recover", "infrahub recover", "infrahub-recover"), ) print(" - Generate Infrahub CLI documentation") diff --git a/tasks/shared.py b/tasks/shared.py index b5664608f7e..31bf7a94d8a 100644 --- a/tasks/shared.py +++ b/tasks/shared.py @@ -145,6 +145,7 @@ class Namespace(StrEnum): "dropdown": "str", "enum": "str", "hashedpassword": "str", + "ipaddress": "str", "iphost": "str", "ipnetwork": "str", "json": "dict", diff --git a/tests/e2e/proposed-changes/test_proposed_changes_ordering.py b/tests/e2e/proposed-changes/test_proposed_changes_ordering.py index 564464d0900..96a839f505d 100644 --- a/tests/e2e/proposed-changes/test_proposed_changes_ordering.py +++ b/tests/e2e/proposed-changes/test_proposed_changes_ordering.py @@ -1,23 +1,32 @@ -"""/proposed-changes list ordering. +"""/proposed-changes list ordering and the Sort picker. -The proposed-changes list (both the Opened and Closed tabs) must be ordered by -creation date, newest first. Regression guard for the list previously coming -back in an arbitrary (node-uuid) order, which buried recently created proposed -changes. +The proposed-changes list (both the Opened and Closed tabs) defaults to creation +date, newest first. Regression guard for the list previously coming back in an +arbitrary (node-uuid) order, which buried recently created proposed changes. -The test owns all of its data: it creates three throwaway branches and one -proposed change per branch through the SDK, so it needs neither the demo -dataset nor the demo-edge repository. +The toolbar carries the generic Sort picker: choosing a field (e.g. the +"Created at" / "Updated at" node metadata) and a direction persists the order in +the `sort` query param. The default order is the absence of a param — the +CoreProposedChange schema defines no order_by, so clearing the custom sort +drops the param and the list falls back to newest created first. -Note on the Closed tab: ordering is by *creation* time, not by when each -proposed change was closed. The test closes the proposed changes in reverse -creation order to prove the list still comes back newest-created-first (the -most recently closed one is not floated to the top). +The filter bar's field chips open the object table's column-header menu, so a +chip offers both sorting and filtering for its field from one popover. + +The tests own all of their data: three throwaway branches and one proposed +change per branch through the SDK, so they need neither the demo dataset nor the +demo-edge repository. + +Note on the default order: it is by *creation* time, never by when a proposed +change was closed. The tests close proposed changes in reverse creation order, +which keeps the default order stable while making the update order the exact +reverse — so an assertion on one cannot pass under the other. """ from __future__ import annotations import contextlib +import re from typing import TYPE_CHECKING import pytest @@ -35,11 +44,16 @@ async def _rendered_pc_order(page: Page) -> list[str]: - """Return the proposed-change ids in the order they are rendered in the list.""" + """Return the proposed-change ids in the order they are rendered in the list. + + Changing the order restarts the query, which empties the list until the first + page of the new order arrives — so wait for a row before reading, otherwise + the scrape races the refetch and comes back empty. + """ listbox = page.get_by_role("listbox") - hrefs = await listbox.locator('a[href*="/proposed-changes/"]').evaluate_all( - "els => els.map((e) => e.getAttribute('href'))" - ) + rows = listbox.locator('a[href*="/proposed-changes/"]') + await expect(rows.first).to_be_visible() + hrefs = await rows.evaluate_all("els => els.map((e) => e.getAttribute('href'))") order: list[str] = [] for href in hrefs: pc_id = href.split("/proposed-changes/", 1)[1].split("?", 1)[0].split("/", 1)[0] @@ -48,6 +62,22 @@ async def _rendered_pc_order(page: Page) -> list[str]: return order +async def _open_sort_picker(page: Page) -> None: + """Open the Sort picker popover (the trigger carries a count badge once a sort is applied).""" + await page.get_by_role("button", name=re.compile(r"^Sort( \d+)?$")).click() + + +async def _add_sort(page: Page, field: str, direction: str) -> None: + """Pick a sort from scratch: choose a field, then its direction in the submenu. + + Ends with Escape so the popover doesn't cover the first rows of the list. + """ + await _open_sort_picker(page) + await page.get_by_role("menuitem", name=field).click() + await page.get_by_role("menuitem", name=direction).click() + await page.keyboard.press("Escape") + + class TestProposedChangesOrdering: @pytest.fixture async def proposed_changes( @@ -97,3 +127,88 @@ async def test_list_is_ordered_newest_first(self, admin_page: Page, proposed_cha await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() closed_order = await _rendered_pc_order(admin_page) assert [pc_id for pc_id in closed_order if pc_id in expected] == expected + + async def test_sort_picker_flips_the_creation_order( + self, admin_page: Page, proposed_changes: list[InfrahubNode] + ) -> None: + oldest, middle, newest = proposed_changes + newest_first = [newest.id, middle.id, oldest.id] + + await admin_page.goto("/proposed-changes") + await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() + + await _add_sort(admin_page, "Created at", "Ascending") + + await expect(admin_page).to_have_url(re.compile(r"sort=node_metadata__created_at__asc")) + oldest_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in oldest_order if pc_id in newest_first] == list(reversed(newest_first)) + + # The default order is the absence of a param, so clearing the sort drops it entirely. + await _open_sort_picker(admin_page) + await admin_page.get_by_role("button", name="Clear sort").click() + await admin_page.keyboard.press("Escape") + + await expect(admin_page).not_to_have_url(re.compile(r"sort=")) + restored_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in restored_order if pc_id in newest_first] == newest_first + + async def test_sort_picker_orders_by_update_date( + self, admin_page: Page, proposed_changes: list[InfrahubNode] + ) -> None: + oldest, middle, newest = proposed_changes + newest_first = [newest.id, middle.id, oldest.id] + + # Closing in reverse creation order makes the update order the exact reverse of the + # creation order, so neither assertion below can pass under the other ordering. + for pc in (newest, middle, oldest): + pc.state.value = "closed" + await pc.save() + + await admin_page.goto("/proposed-changes?pr_state=closed") + await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() + + listbox = admin_page.get_by_role("listbox") + await expect(listbox.get_by_text("Updated")).to_have_count(0) + + await _add_sort(admin_page, "Updated at", "Descending") + + await expect(listbox.get_by_text("Updated").first).to_be_visible() + + await expect(admin_page).to_have_url(re.compile(r"sort=node_metadata__updated_at__desc")) + updated_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in updated_order if pc_id in newest_first] == list(reversed(newest_first)) + + # Flip the direction from the applied-sort row inside the picker. + await _open_sort_picker(admin_page) + await admin_page.get_by_role("button", name=re.compile(r"Sort direction")).click() + await admin_page.get_by_role("option", name="Ascending").click() + await admin_page.keyboard.press("Escape") + + await expect(admin_page).to_have_url(re.compile(r"sort=node_metadata__updated_at__asc")) + least_updated_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in least_updated_order if pc_id in newest_first] == newest_first + + async def test_field_chip_sorts_and_filters(self, admin_page: Page, proposed_changes: list[InfrahubNode]) -> None: + oldest, middle, newest = proposed_changes + newest_first = [newest.id, middle.id, oldest.id] + + await admin_page.goto("/proposed-changes") + await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() + + # Branch names embed their creation index, so their ascending order is the creation order. + await admin_page.get_by_role("button", name="Source Branch").click() + await admin_page.get_by_role("menuitem", name="Sort ascending").click() + + await expect(admin_page).to_have_url(re.compile(r"sort=source_branch__value__asc")) + branch_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in branch_order if pc_id in newest_first] == list(reversed(newest_first)) + + # The same chip also filters its field. + await admin_page.get_by_role("button", name="Source Branch").click() + await admin_page.get_by_role("menuitem", name="Filter").click() + filter_form = admin_page.get_by_test_id("attribute-filter-form") + await filter_form.get_by_role("textbox").fill(str(newest.source_branch.value)) + await filter_form.get_by_role("button", name="Apply").click() + + await expect(admin_page.locator(f'a[href*="/proposed-changes/{oldest.id}"]')).not_to_be_visible() + await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible()