@backstage/plugin-catalog-backend 3.6.2-next.1 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/config.d.ts +5 -1
  3. package/dist/actions/createQueryCatalogEntitiesAction.cjs.js +57 -49
  4. package/dist/actions/createQueryCatalogEntitiesAction.cjs.js.map +1 -1
  5. package/dist/actions/createUnregisterCatalogEntitiesAction.cjs.js +4 -4
  6. package/dist/actions/createUnregisterCatalogEntitiesAction.cjs.js.map +1 -1
  7. package/dist/actions/index.cjs.js.map +1 -1
  8. package/dist/database/metrics.cjs.js +58 -14
  9. package/dist/database/metrics.cjs.js.map +1 -1
  10. package/dist/database/operations/stitcher/buildEntitySearch.cjs.js +9 -1
  11. package/dist/database/operations/stitcher/buildEntitySearch.cjs.js.map +1 -1
  12. package/dist/database/operations/stitcher/performStitching.cjs.js +10 -2
  13. package/dist/database/operations/stitcher/performStitching.cjs.js.map +1 -1
  14. package/dist/database/operations/stitcher/syncSearchRows.cjs.js +14 -5
  15. package/dist/database/operations/stitcher/syncSearchRows.cjs.js.map +1 -1
  16. package/dist/database/operations/stitcher/util.cjs.js +2 -11
  17. package/dist/database/operations/stitcher/util.cjs.js.map +1 -1
  18. package/dist/processing/DefaultCatalogProcessingEngine.cjs.js +3 -1
  19. package/dist/processing/DefaultCatalogProcessingEngine.cjs.js.map +1 -1
  20. package/dist/service/CatalogPlugin.cjs.js +4 -1
  21. package/dist/service/CatalogPlugin.cjs.js.map +1 -1
  22. package/dist/service/DefaultEntitiesCatalog.cjs.js +132 -47
  23. package/dist/service/DefaultEntitiesCatalog.cjs.js.map +1 -1
  24. package/dist/stitching/DefaultStitcher.cjs.js +3 -1
  25. package/dist/stitching/DefaultStitcher.cjs.js.map +1 -1
  26. package/dist/stitching/types.cjs.js +8 -1
  27. package/dist/stitching/types.cjs.js.map +1 -1
  28. package/migrations/20260510000000_search_indices_and_dedup.js +439 -0
  29. package/migrations/20260516000000_relations_target_index.js +92 -0
  30. package/package.json +21 -21
package/CHANGELOG.md CHANGED
@@ -1,5 +1,138 @@
1
1
  # @backstage/plugin-catalog-backend
2
2
 
3
+ ## 3.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c2de113: **BREAKING**: When paginating entities with an order field via `/entities/by-query`, entities that lack the order field are now excluded from both the result set and the `totalItems` count. Previously these entities appeared at the end of the sorted result via `NULLS LAST`, but cursor-based pagination could not actually reach them past the first page — the count over-reported the number of navigable entities. The new behavior aligns the count with what is actually returned.
8
+
9
+ This also removes the `DISTINCT` deduplication from the sort-field CTE, which is a prerequisite for the planner to use the `(key, value, entity_id)` index in sort order and short-circuit on `LIMIT`. Installations with duplicate search rows should land the search-table deduplication migration before adopting this change.
10
+
11
+ ### Patch Changes
12
+
13
+ - 3f5e7ec: Added `catalog.actions.experimentalCatalogLayersDescriptions.enabled` config option. When enabled, the `query-catalog-entities` action description references `get-catalog-model-description` for field information instead of embedding a static model description.
14
+ - ccbad9d: Improved the performance of the `catalog_entities_count` metric.
15
+
16
+ The legacy Prometheus and OpenTelemetry observable gauges previously each ran their own copy of the per-kind count query against the `search` table on every metrics scrape. On large catalogs this could pile up faster than the queries completed, contending for buffers and stalling the database.
17
+
18
+ The two callbacks now share a single query result with a short in-process TTL cache, and the underlying query reads from `final_entities` instead of `search`, avoiding the bitmap heap scans that dominated the previous form. The emitted labels and values are unchanged.
19
+
20
+ - 17a9550: Deprecated immediate mode stitching (`catalog.stitchingStrategy.mode: 'immediate'`). A warning is now logged on startup when immediate mode is configured. Immediate mode will be removed in the next Backstage release. Migrate to deferred mode (the default) by removing the `stitchingStrategy` configuration or setting `mode: 'deferred'`.
21
+ - add5d1a: Restructured the entity listing endpoint so that, when a sort field is specified, the search-by-key index drives the query rather than being side-joined onto `final_entities`. This lets PostgreSQL walk the `(key, value, entity_id)` index in already-sorted order and short-circuit on `LIMIT`, reducing typical broad-filter paginated list times from seconds to milliseconds. Entities that lack the sort field still appear at the end of sorted results (NULLS LAST semantics preserved), ordered by `entity_id`.
22
+ - 387ea7d: Simplified the entity facets aggregation from `COUNT(DISTINCT entity_id)` to `COUNT(*)`. The unique constraint on `(entity_id, key, value)` guarantees each entity appears at most once per search row group, making the `DISTINCT` unnecessary. This allows the database to use a simpler aggregation plan.
23
+ - 3f55b73: Improved the performance of the entity facets endpoint when filters are applied. The filtered entity set is now combined with the search table through an inner join rather than a `WHERE entity_id IN (subquery)`. Results are unchanged; on large catalogs the query planner is able to choose dramatically cheaper plans, with measured improvements ranging from roughly 1.2× on already-fast cases to 7× or more on high-cardinality facets.
24
+ - b33f845: Fixed several database migration `down` functions that were not properly reversible, causing the SQL report to show warnings:
25
+
26
+ - `20241003170511_alter_target_in_locations.js`: both `up` and `down` now include `.notNullable()` when altering the `locations.target` column, preventing the `NOT NULL` constraint from being accidentally dropped when widening the column type from `varchar(255)` to `text`.
27
+ - `20220116144621_remove_legacy.js`: the `down` function now properly recreates the three dropped legacy tables (`entities`, `entities_search`, `entities_relations`) with correct columns and indices.
28
+ - `20210302150147_refresh_state.js`: the `down` function now drops dependent tables in the correct order (avoiding a FK constraint violation) and fixes a typo where the table was referred to as `references` instead of `refresh_state_references`.
29
+ - `20201005122705_add_entity_full_name.js`: the `down` function now drops the `full_name` column from `entities` (not `entities_search`), and restores the `entities_unique_name` index with the correct column order `(kind, name, namespace)`.
30
+ - `20200702153613_entities.js`: the `down` function now uses `table.integer('generation')` instead of `table.string('generation')`, restoring the correct column type.
31
+
32
+ - cde3643: Added missing description to the `type` parameter on the `unregister-entity` MCP action.
33
+ - cf195de: Fixed a performance regression in the `/entity-facets` endpoint when filters or permission conditions are applied, by routing the EXISTS-based filter through `final_entities` instead of correlating against the much larger `search` table.
34
+ - 07ec25d: Moved `generateStableHash` out of shared utility file to avoid pulling `node:crypto` into browser bundles
35
+ - bc32c13: Added a missing index on `relations.target_entity_ref`. Several query paths (orphan deletion, entity ancestry, eager pruning) join or filter on this column, but no index existed — causing full sequential scans of the relations table on every invocation. On a production catalog with ~3.5M relation rows, individual lookups were taking ~122ms (full table scan) instead of <1ms (index scan).
36
+ - 744fa1f: Removed duplicated entries that appeared in both `dependencies` and `devDependencies`.
37
+ - e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
38
+ - 7445f0f: Added a migration that removes duplicate rows from the `search` table, creates covering indices for improved query performance, and adds a `UNIQUE` constraint on `(entity_id, key, value)`.
39
+
40
+ This is a long-running migration on large catalogs. On PostgreSQL with millions of search rows, the index creation may take 5-15 minutes per index. During this time, other pods running the previous version will continue to serve traffic normally — the index creation does not block reads or writes. However, if a Kubernetes liveness probe kills the pod before the index build completes, the build is lost and the next startup will start over. On large tables this can repeat indefinitely.
41
+
42
+ **For large installations**, it is recommended to run the following SQL commands against your PostgreSQL database **before deploying** this version. Each index build takes a few minutes but does not block reads or writes. If these have already completed, the migration will detect the existing indices and skip all work — startup will be instant.
43
+
44
+ ```sql
45
+ -- Step 1: Remove duplicate search rows
46
+ WITH cte AS (
47
+ SELECT ctid, row_number() OVER (PARTITION BY entity_id, key, value) AS rn
48
+ FROM search
49
+ )
50
+ DELETE FROM search USING cte WHERE search.ctid = cte.ctid AND cte.rn > 1;
51
+
52
+ -- Step 2: Create new indices (run each separately)
53
+ CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS
54
+ search_entity_key_value_idx ON search (entity_id, key, value);
55
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
56
+ search_key_value_entity_idx ON search (key, value, entity_id);
57
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
58
+ search_facets_covering_idx ON search (key, original_value, entity_id)
59
+ WHERE original_value IS NOT NULL;
60
+
61
+ -- Step 3: Drop old indices that are no longer needed
62
+ DROP INDEX CONCURRENTLY IF EXISTS search_key_value_idx;
63
+ DROP INDEX CONCURRENTLY IF EXISTS search_key_original_value_idx;
64
+ ```
65
+
66
+ Also fixed `buildEntitySearch` to remove duplicate output for entities with duplicate array values, and added `ON CONFLICT DO UPDATE` to `syncSearchRows` so that concurrent stitching races are handled gracefully.
67
+
68
+ - Updated dependencies
69
+ - @backstage/catalog-model@1.9.0
70
+ - @backstage/errors@1.3.1
71
+ - @backstage/backend-plugin-api@1.9.1
72
+ - @backstage/plugin-catalog-node@2.2.1
73
+ - @backstage/filter-predicates@0.1.3
74
+ - @backstage/integration@2.0.2
75
+ - @backstage/plugin-permission-node@0.11.0
76
+ - @backstage/plugin-permission-common@0.9.9
77
+ - @backstage/backend-openapi-utils@0.6.9
78
+ - @backstage/catalog-client@1.15.1
79
+ - @backstage/config@1.3.8
80
+ - @backstage/plugin-catalog-common@1.1.10
81
+ - @backstage/plugin-events-node@0.4.22
82
+
83
+ ## 3.7.0-next.2
84
+
85
+ ### Minor Changes
86
+
87
+ - c2de113: **BREAKING**: When paginating entities with an order field via `/entities/by-query`, entities that lack the order field are now excluded from both the result set and the `totalItems` count. Previously these entities appeared at the end of the sorted result via `NULLS LAST`, but cursor-based pagination could not actually reach them past the first page — the count over-reported the number of navigable entities. The new behavior aligns the count with what is actually returned.
88
+
89
+ This also removes the `DISTINCT` deduplication from the sort-field CTE, which is a prerequisite for the planner to use the `(key, value, entity_id)` index in sort order and short-circuit on `LIMIT`. Installations with duplicate search rows should land the search-table deduplication migration before adopting this change.
90
+
91
+ ### Patch Changes
92
+
93
+ - ccbad9d: Improved the performance of the `catalog_entities_count` metric.
94
+
95
+ The legacy Prometheus and OpenTelemetry observable gauges previously each ran their own copy of the per-kind count query against the `search` table on every metrics scrape. On large catalogs this could pile up faster than the queries completed, contending for buffers and stalling the database.
96
+
97
+ The two callbacks now share a single query result with a short in-process TTL cache, and the underlying query reads from `final_entities` instead of `search`, avoiding the bitmap heap scans that dominated the previous form. The emitted labels and values are unchanged.
98
+
99
+ - add5d1a: Restructured the entity listing endpoint so that, when a sort field is specified, the search-by-key index drives the query rather than being side-joined onto `final_entities`. This lets PostgreSQL walk the `(key, value, entity_id)` index in already-sorted order and short-circuit on `LIMIT`, reducing typical broad-filter paginated list times from seconds to milliseconds. Entities that lack the sort field still appear at the end of sorted results (NULLS LAST semantics preserved), ordered by `entity_id`.
100
+ - 387ea7d: Simplified the entity facets aggregation from `COUNT(DISTINCT entity_id)` to `COUNT(*)`. The unique constraint on `(entity_id, key, value)` guarantees each entity appears at most once per search row group, making the `DISTINCT` unnecessary. This allows the database to use a simpler aggregation plan.
101
+ - 3f55b73: Improved the performance of the entity facets endpoint when filters are applied. The filtered entity set is now combined with the search table through an inner join rather than a `WHERE entity_id IN (subquery)`. Results are unchanged; on large catalogs the query planner is able to choose dramatically cheaper plans, with measured improvements ranging from roughly 1.2× on already-fast cases to 7× or more on high-cardinality facets.
102
+ - cde3643: Added missing description to the `type` parameter on the `unregister-entity` MCP action.
103
+ - 7445f0f: Added a migration that removes duplicate rows from the `search` table, creates covering indices for improved query performance, and adds a `UNIQUE` constraint on `(entity_id, key, value)`.
104
+
105
+ This is a long-running migration on large catalogs. On PostgreSQL with millions of search rows, the index creation may take 5-15 minutes per index. During this time, other pods running the previous version will continue to serve traffic normally — the index creation does not block reads or writes. However, if a Kubernetes liveness probe kills the pod before the index build completes, the build is lost and the next startup will start over. On large tables this can repeat indefinitely.
106
+
107
+ **For large installations**, it is recommended to run the following SQL commands against your PostgreSQL database **before deploying** this version. Each index build takes a few minutes but does not block reads or writes. If these have already completed, the migration will detect the existing indices and skip all work — startup will be instant.
108
+
109
+ ```sql
110
+ -- Step 1: Remove duplicate search rows
111
+ WITH cte AS (
112
+ SELECT ctid, row_number() OVER (PARTITION BY entity_id, key, value) AS rn
113
+ FROM search
114
+ )
115
+ DELETE FROM search USING cte WHERE search.ctid = cte.ctid AND cte.rn > 1;
116
+
117
+ -- Step 2: Create new indices (run each separately)
118
+ CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS
119
+ search_entity_key_value_idx ON search (entity_id, key, value);
120
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
121
+ search_key_value_entity_idx ON search (key, value, entity_id);
122
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
123
+ search_facets_covering_idx ON search (key, original_value, entity_id)
124
+ WHERE original_value IS NOT NULL;
125
+
126
+ -- Step 3: Drop old indices that are no longer needed
127
+ DROP INDEX CONCURRENTLY IF EXISTS search_key_value_idx;
128
+ DROP INDEX CONCURRENTLY IF EXISTS search_key_original_value_idx;
129
+ ```
130
+
131
+ Also fixed `buildEntitySearch` to remove duplicate output for entities with duplicate array values, and added `ON CONFLICT DO UPDATE` to `syncSearchRows` so that concurrent stitching races are handled gracefully.
132
+
133
+ - Updated dependencies
134
+ - @backstage/backend-plugin-api@1.9.1-next.1
135
+
3
136
  ## 3.6.2-next.1
4
137
 
5
138
  ### Patch Changes
package/config.d.ts CHANGED
@@ -179,7 +179,11 @@ export interface Config {
179
179
  */
180
180
  stitchingStrategy?:
181
181
  | {
182
- /** Perform stitching in-band immediately when needed */
182
+ /**
183
+ * Perform stitching in-band immediately when needed.
184
+ *
185
+ * @deprecated Immediate mode stitching has been deprecated and will be removed in a future release. Migrate to deferred mode (the default).
186
+ */
183
187
  mode: 'immediate';
184
188
  }
185
189
  | {
@@ -2,19 +2,44 @@
2
2
 
3
3
  var filterPredicates = require('@backstage/filter-predicates');
4
4
 
5
- const createQueryCatalogEntitiesAction = ({
6
- catalog,
7
- actionsRegistry
8
- }) => {
9
- actionsRegistry.register({
10
- name: "query-catalog-entities",
11
- title: "Query Catalog Entities",
12
- attributes: {
13
- destructive: false,
14
- readOnly: true,
15
- idempotent: true
16
- },
17
- description: `
5
+ const QUERY_SYNTAX = `
6
+ ## Query Syntax
7
+
8
+ The query uses predicate expressions with dot-notation field paths.
9
+
10
+ Simple matching:
11
+ { query: { kind: "Component" } }
12
+ { query: { kind: "Component", "spec.type": "service" } }
13
+
14
+ Value operators:
15
+ { query: { kind: { "$in": ["API", "Component"] } } }
16
+ { query: { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } }
17
+ { query: { "metadata.tags": { "$contains": "java" } } }
18
+ { query: { "metadata.name": { "$hasPrefix": "team-" } } }
19
+
20
+ Logical operators:
21
+ { query: { "$all": [{ kind: "Component" }, { "spec.lifecycle": "production" }] } }
22
+ { query: { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] } }
23
+ { query: { "$not": { kind: "Group" } } }
24
+
25
+ Querying relations - find all entities owned by a specific group:
26
+ { query: { "relations.ownedby": "group:default/team-alpha" } }
27
+
28
+ Combined example - find production services or websites with TechDocs:
29
+ { query: { "$all": [
30
+ { kind: "Component", "spec.lifecycle": "production" },
31
+ { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] },
32
+ { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } }
33
+ ] } }
34
+
35
+ ## Other Options
36
+
37
+ Limit returned fields: { fields: ["kind", "metadata.name", "metadata.namespace"] }
38
+ Sort results: { orderFields: { field: "metadata.name", order: "asc" } }
39
+ Full text search: { fullTextFilter: { term: "auth", fields: ["metadata.name", "metadata.title"] } }
40
+ Pagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor.
41
+ `;
42
+ const INLINE_MODEL_DESCRIPTION = `
18
43
  Query entities from the Backstage Software Catalog using predicate filters.
19
44
 
20
45
  ## Catalog Model
@@ -58,43 +83,26 @@ Entities have bidirectional relations stored in the "relations" array. Common re
58
83
  Relations can be queried via "relations.<type>" e.g. "relations.ownedby: user:default/jane-doe". The value there must always be a valid entity reference.
59
84
 
60
85
  When querying for entity relationships, prefer using relations over spec fields. For example, use "relations.ownedby" instead of "spec.owner" to find entities owned by a particular group or user.
86
+ ${QUERY_SYNTAX}`;
87
+ const MODEL_REFERENCE_DESCRIPTION = `
88
+ Query entities from the Backstage Software Catalog using predicate filters.
61
89
 
62
- ## Query Syntax
63
-
64
- The query uses predicate expressions with dot-notation field paths.
65
-
66
- Simple matching:
67
- { query: { kind: "Component" } }
68
- { query: { kind: "Component", "spec.type": "service" } }
69
-
70
- Value operators:
71
- { query: { kind: { "$in": ["API", "Component"] } } }
72
- { query: { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } } }
73
- { query: { "metadata.tags": { "$contains": "java" } } }
74
- { query: { "metadata.name": { "$hasPrefix": "team-" } } }
75
-
76
- Logical operators:
77
- { query: { "$all": [{ kind: "Component" }, { "spec.lifecycle": "production" }] } }
78
- { query: { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] } }
79
- { query: { "$not": { kind: "Group" } } }
80
-
81
- Querying relations - find all entities owned by a specific group:
82
- { query: { "relations.ownedby": "group:default/team-alpha" } }
83
-
84
- Combined example - find production services or websites with TechDocs:
85
- { query: { "$all": [
86
- { kind: "Component", "spec.lifecycle": "production" },
87
- { "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] },
88
- { "metadata.annotations.backstage.io/techdocs-ref": { "$exists": true } }
89
- ] } }
90
-
91
- ## Other Options
92
-
93
- Limit returned fields: { fields: ["kind", "metadata.name", "metadata.namespace"] }
94
- Sort results: { orderFields: { field: "metadata.name", order: "asc" } }
95
- Full text search: { fullTextFilter: { term: "auth", fields: ["metadata.name", "metadata.title"] } }
96
- Pagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor.
97
- `,
90
+ For a complete list of entity kinds, fields, relations, and other queryable attributes available in the catalog, use \`get-catalog-model-description\`.
91
+ ${QUERY_SYNTAX}`;
92
+ const createQueryCatalogEntitiesAction = ({
93
+ catalog,
94
+ actionsRegistry,
95
+ useExperimentalCatalogLayersDescriptions
96
+ }) => {
97
+ actionsRegistry.register({
98
+ name: "query-catalog-entities",
99
+ title: "Query Catalog Entities",
100
+ attributes: {
101
+ destructive: false,
102
+ readOnly: true,
103
+ idempotent: true
104
+ },
105
+ description: useExperimentalCatalogLayersDescriptions ? MODEL_REFERENCE_DESCRIPTION : INLINE_MODEL_DESCRIPTION,
98
106
  schema: {
99
107
  input: (z) => z.object({
100
108
  query: filterPredicates.createZodV3FilterPredicateSchema(z).optional().describe(
@@ -1 +1 @@
1
- {"version":3,"file":"createQueryCatalogEntitiesAction.cjs.js","sources":["../../src/actions/createQueryCatalogEntitiesAction.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { CatalogService } from '@backstage/plugin-catalog-node';\nimport { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates';\n\nexport const createQueryCatalogEntitiesAction = ({\n catalog,\n actionsRegistry,\n}: {\n catalog: CatalogService;\n actionsRegistry: ActionsRegistryService;\n}) => {\n actionsRegistry.register({\n name: 'query-catalog-entities',\n title: 'Query Catalog Entities',\n attributes: {\n destructive: false,\n readOnly: true,\n idempotent: true,\n },\n description: `\nQuery entities from the Backstage Software Catalog using predicate filters.\n\n## Catalog Model\n\nThe catalog contains entities of different kinds. Every entity has \"kind\", \"apiVersion\", \"metadata\", and optionally \"spec\" and \"relations\". Fields use dot notation for querying.\n\nCommon metadata fields on all entities: name, namespace (default: \"default\"), title, description, labels, annotations, tags (string array), links.\n\nEntity references use the format \"kind:namespace/name\", e.g. \"component:default/my-service\" or \"user:default/jane.doe\".\n\n### Entity Kinds\n\n**Component** - A piece of software such as a service, website, or library.\n spec fields: type (e.g. \"service\", \"website\", \"library\"), lifecycle (e.g. \"production\", \"experimental\", \"deprecated\"), owner (entity ref), system, subcomponentOf, providesApis, consumesApis, dependsOn, dependencyOf.\n\n**API** - An interface that components expose, such as REST APIs or event streams.\n spec fields: type (e.g. \"openapi\", \"asyncapi\", \"graphql\", \"grpc\"), lifecycle, owner (entity ref), definition (the API spec content), system.\n\n**System** - A collection of components, APIs, and resources that together expose some functionality.\n spec fields: owner (entity ref), domain, type.\n\n**Domain** - A grouping of systems that share terminology, domain models, and business purpose.\n spec fields: owner (entity ref), subdomainOf, type.\n\n**Resource** - Infrastructure required to operate a component, such as databases or storage buckets.\n spec fields: type, owner (entity ref), system, dependsOn, dependencyOf.\n\n**Group** - An organizational entity such as a team or business unit.\n spec fields: type (e.g. \"team\", \"business-unit\"), children (entity refs), parent (entity ref), members (entity refs), profile (displayName, email, picture).\n\n**User** - A person, such as an employee or contractor.\n spec fields: memberOf (entity refs), profile (displayName, email, picture).\n\n**Location** - A marker that references other catalog descriptor files to be ingested.\n spec fields: type, target, targets, presence.\n\n### Relations\n\nEntities have bidirectional relations stored in the \"relations\" array. Common relation types: ownedBy/ownerOf, dependsOn/dependencyOf, providesApi/apiProvidedBy, consumesApi/apiConsumedBy, parentOf/childOf, memberOf/hasMember, partOf/hasPart.\n\nRelations can be queried via \"relations.<type>\" e.g. \"relations.ownedby: user:default/jane-doe\". The value there must always be a valid entity reference.\n\nWhen querying for entity relationships, prefer using relations over spec fields. For example, use \"relations.ownedby\" instead of \"spec.owner\" to find entities owned by a particular group or user.\n\n## Query Syntax\n\nThe query uses predicate expressions with dot-notation field paths.\n\nSimple matching:\n { query: { kind: \"Component\" } }\n { query: { kind: \"Component\", \"spec.type\": \"service\" } }\n\nValue operators:\n { query: { kind: { \"$in\": [\"API\", \"Component\"] } } }\n { query: { \"metadata.annotations.backstage.io/techdocs-ref\": { \"$exists\": true } } }\n { query: { \"metadata.tags\": { \"$contains\": \"java\" } } }\n { query: { \"metadata.name\": { \"$hasPrefix\": \"team-\" } } }\n\nLogical operators:\n { query: { \"$all\": [{ kind: \"Component\" }, { \"spec.lifecycle\": \"production\" }] } }\n { query: { \"$any\": [{ \"spec.type\": \"service\" }, { \"spec.type\": \"website\" }] } }\n { query: { \"$not\": { kind: \"Group\" } } }\n\nQuerying relations - find all entities owned by a specific group:\n { query: { \"relations.ownedby\": \"group:default/team-alpha\" } }\n\nCombined example - find production services or websites with TechDocs:\n { query: { \"$all\": [\n { kind: \"Component\", \"spec.lifecycle\": \"production\" },\n { \"$any\": [{ \"spec.type\": \"service\" }, { \"spec.type\": \"website\" }] },\n { \"metadata.annotations.backstage.io/techdocs-ref\": { \"$exists\": true } }\n ] } }\n\n## Other Options\n\nLimit returned fields: { fields: [\"kind\", \"metadata.name\", \"metadata.namespace\"] }\nSort results: { orderFields: { field: \"metadata.name\", order: \"asc\" } }\nFull text search: { fullTextFilter: { term: \"auth\", fields: [\"metadata.name\", \"metadata.title\"] } }\nPagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor.\n `,\n schema: {\n input: z =>\n z.object({\n query: createZodV3FilterPredicateSchema(z)\n .optional()\n .describe(\n 'Entity predicate query. Supports field matching, $all, $any, $not, $exists, $in, $contains, and $hasPrefix operators.',\n ),\n fields: z\n .array(z.string())\n .optional()\n .describe(\n 'Specific fields to include in the response. If not provided, all fields are returned. Each entry is a dot separated path into an entity, e.g. `spec.type`.',\n ),\n limit: z\n .number()\n .int()\n .positive()\n .optional()\n .describe('Maximum number of entities to return at a time.'),\n offset: z\n .number()\n .int()\n .min(0)\n .optional()\n .describe('Number of entities to skip before returning results.'),\n orderFields: z\n .union([\n z.object({\n field: z\n .string()\n .describe(\n 'Field to order by. The format is a dot separated path into an entity, e.g. `spec.type`.',\n ),\n order: z.enum(['asc', 'desc']).describe('Sort order'),\n }),\n z.array(\n z.object({\n field: z\n .string()\n .describe(\n 'Field to order by. The format is a dot separated path into an entity, e.g. `spec.type`.',\n ),\n order: z.enum(['asc', 'desc']).describe('Sort order'),\n }),\n ),\n ])\n .optional()\n .describe(\n 'Ordering criteria for the results. Can be a single order directive or an array for multi-field sorting.',\n ),\n fullTextFilter: z\n .object({\n term: z.string().describe('Full text search term'),\n fields: z\n .array(z.string())\n .optional()\n .describe(\n 'Fields to search within. Each entry is a dot separated path into an entity, e.g. `spec.type`.',\n ),\n })\n .optional()\n .describe('Full text search criteria'),\n cursor: z\n .string()\n .optional()\n .describe(\n 'Cursor for pagination. This can be used only after the first request with a response containing a cursor. If a cursor is given it takes precedence over `offset`.',\n ),\n }),\n output: z =>\n z.object({\n items: z\n .array(z.object({}).passthrough())\n .describe('List of entities'),\n totalItems: z.number().describe('Total number of entities'),\n hasMoreEntities: z\n .boolean()\n .describe('Whether more entities are available'),\n nextPageCursor: z\n .string()\n .optional()\n .describe('Next page cursor used to fetch next page of entities'),\n }),\n },\n action: async ({ input, credentials }) => {\n const response = await catalog.queryEntities(\n {\n ...input,\n query: input.query,\n },\n { credentials },\n );\n\n return {\n output: {\n items: response.items,\n totalItems: response.totalItems,\n hasMoreEntities: !!response.pageInfo.nextCursor,\n nextPageCursor: response.pageInfo.nextCursor,\n },\n };\n },\n });\n};\n"],"names":["createZodV3FilterPredicateSchema"],"mappings":";;;;AAmBO,MAAM,mCAAmC,CAAC;AAAA,EAC/C,OAAA;AAAA,EACA;AACF,CAAA,KAGM;AACJ,EAAA,eAAA,CAAgB,QAAA,CAAS;AAAA,IACvB,IAAA,EAAM,wBAAA;AAAA,IACN,KAAA,EAAO,wBAAA;AAAA,IACP,UAAA,EAAY;AAAA,MACV,WAAA,EAAa,KAAA;AAAA,MACb,QAAA,EAAU,IAAA;AAAA,MACV,UAAA,EAAY;AAAA,KACd;AAAA,IACA,WAAA,EAAa;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA,IAAA,CAAA;AAAA,IAiFb,MAAA,EAAQ;AAAA,MACN,KAAA,EAAO,CAAA,CAAA,KACL,CAAA,CAAE,MAAA,CAAO;AAAA,QACP,KAAA,EAAOA,iDAAA,CAAiC,CAAC,CAAA,CACtC,UAAS,CACT,QAAA;AAAA,UACC;AAAA,SACF;AAAA,QACF,MAAA,EAAQ,EACL,KAAA,CAAM,CAAA,CAAE,QAAQ,CAAA,CAChB,UAAS,CACT,QAAA;AAAA,UACC;AAAA,SACF;AAAA,QACF,KAAA,EAAO,CAAA,CACJ,MAAA,EAAO,CACP,GAAA,EAAI,CACJ,QAAA,EAAS,CACT,QAAA,EAAS,CACT,QAAA,CAAS,iDAAiD,CAAA;AAAA,QAC7D,MAAA,EAAQ,CAAA,CACL,MAAA,EAAO,CACP,GAAA,EAAI,CACJ,GAAA,CAAI,CAAC,CAAA,CACL,QAAA,EAAS,CACT,QAAA,CAAS,sDAAsD,CAAA;AAAA,QAClE,WAAA,EAAa,EACV,KAAA,CAAM;AAAA,UACL,EAAE,MAAA,CAAO;AAAA,YACP,KAAA,EAAO,CAAA,CACJ,MAAA,EAAO,CACP,QAAA;AAAA,cACC;AAAA,aACF;AAAA,YACF,KAAA,EAAO,EAAE,IAAA,CAAK,CAAC,OAAO,MAAM,CAAC,CAAA,CAAE,QAAA,CAAS,YAAY;AAAA,WACrD,CAAA;AAAA,UACD,CAAA,CAAE,KAAA;AAAA,YACA,EAAE,MAAA,CAAO;AAAA,cACP,KAAA,EAAO,CAAA,CACJ,MAAA,EAAO,CACP,QAAA;AAAA,gBACC;AAAA,eACF;AAAA,cACF,KAAA,EAAO,EAAE,IAAA,CAAK,CAAC,OAAO,MAAM,CAAC,CAAA,CAAE,QAAA,CAAS,YAAY;AAAA,aACrD;AAAA;AACH,SACD,CAAA,CACA,QAAA,EAAS,CACT,QAAA;AAAA,UACC;AAAA,SACF;AAAA,QACF,cAAA,EAAgB,EACb,MAAA,CAAO;AAAA,UACN,IAAA,EAAM,CAAA,CAAE,MAAA,EAAO,CAAE,SAAS,uBAAuB,CAAA;AAAA,UACjD,MAAA,EAAQ,EACL,KAAA,CAAM,CAAA,CAAE,QAAQ,CAAA,CAChB,UAAS,CACT,QAAA;AAAA,YACC;AAAA;AACF,SACH,CAAA,CACA,QAAA,EAAS,CACT,SAAS,2BAA2B,CAAA;AAAA,QACvC,MAAA,EAAQ,CAAA,CACL,MAAA,EAAO,CACP,UAAS,CACT,QAAA;AAAA,UACC;AAAA;AACF,OACH,CAAA;AAAA,MACH,MAAA,EAAQ,CAAA,CAAA,KACN,CAAA,CAAE,MAAA,CAAO;AAAA,QACP,KAAA,EAAO,CAAA,CACJ,KAAA,CAAM,CAAA,CAAE,MAAA,CAAO,EAAE,CAAA,CAAE,WAAA,EAAa,CAAA,CAChC,QAAA,CAAS,kBAAkB,CAAA;AAAA,QAC9B,UAAA,EAAY,CAAA,CAAE,MAAA,EAAO,CAAE,SAAS,0BAA0B,CAAA;AAAA,QAC1D,eAAA,EAAiB,CAAA,CACd,OAAA,EAAQ,CACR,SAAS,qCAAqC,CAAA;AAAA,QACjD,gBAAgB,CAAA,CACb,MAAA,GACA,QAAA,EAAS,CACT,SAAS,sDAAsD;AAAA,OACnE;AAAA,KACL;AAAA,IACA,MAAA,EAAQ,OAAO,EAAE,KAAA,EAAO,aAAY,KAAM;AACxC,MAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,aAAA;AAAA,QAC7B;AAAA,UACE,GAAG,KAAA;AAAA,UACH,OAAO,KAAA,CAAM;AAAA,SACf;AAAA,QACA,EAAE,WAAA;AAAY,OAChB;AAEA,MAAA,OAAO;AAAA,QACL,MAAA,EAAQ;AAAA,UACN,OAAO,QAAA,CAAS,KAAA;AAAA,UAChB,YAAY,QAAA,CAAS,UAAA;AAAA,UACrB,eAAA,EAAiB,CAAC,CAAC,QAAA,CAAS,QAAA,CAAS,UAAA;AAAA,UACrC,cAAA,EAAgB,SAAS,QAAA,CAAS;AAAA;AACpC,OACF;AAAA,IACF;AAAA,GACD,CAAA;AACH;;;;"}
1
+ {"version":3,"file":"createQueryCatalogEntitiesAction.cjs.js","sources":["../../src/actions/createQueryCatalogEntitiesAction.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { CatalogService } from '@backstage/plugin-catalog-node';\nimport { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates';\n\nconst QUERY_SYNTAX = `\n## Query Syntax\n\nThe query uses predicate expressions with dot-notation field paths.\n\nSimple matching:\n { query: { kind: \"Component\" } }\n { query: { kind: \"Component\", \"spec.type\": \"service\" } }\n\nValue operators:\n { query: { kind: { \"$in\": [\"API\", \"Component\"] } } }\n { query: { \"metadata.annotations.backstage.io/techdocs-ref\": { \"$exists\": true } } }\n { query: { \"metadata.tags\": { \"$contains\": \"java\" } } }\n { query: { \"metadata.name\": { \"$hasPrefix\": \"team-\" } } }\n\nLogical operators:\n { query: { \"$all\": [{ kind: \"Component\" }, { \"spec.lifecycle\": \"production\" }] } }\n { query: { \"$any\": [{ \"spec.type\": \"service\" }, { \"spec.type\": \"website\" }] } }\n { query: { \"$not\": { kind: \"Group\" } } }\n\nQuerying relations - find all entities owned by a specific group:\n { query: { \"relations.ownedby\": \"group:default/team-alpha\" } }\n\nCombined example - find production services or websites with TechDocs:\n { query: { \"$all\": [\n { kind: \"Component\", \"spec.lifecycle\": \"production\" },\n { \"$any\": [{ \"spec.type\": \"service\" }, { \"spec.type\": \"website\" }] },\n { \"metadata.annotations.backstage.io/techdocs-ref\": { \"$exists\": true } }\n ] } }\n\n## Other Options\n\nLimit returned fields: { fields: [\"kind\", \"metadata.name\", \"metadata.namespace\"] }\nSort results: { orderFields: { field: \"metadata.name\", order: \"asc\" } }\nFull text search: { fullTextFilter: { term: \"auth\", fields: [\"metadata.name\", \"metadata.title\"] } }\nPagination: Use limit (e.g. 20) and the returned nextPageCursor for subsequent requests via cursor.\n`;\n\nconst INLINE_MODEL_DESCRIPTION = `\nQuery entities from the Backstage Software Catalog using predicate filters.\n\n## Catalog Model\n\nThe catalog contains entities of different kinds. Every entity has \"kind\", \"apiVersion\", \"metadata\", and optionally \"spec\" and \"relations\". Fields use dot notation for querying.\n\nCommon metadata fields on all entities: name, namespace (default: \"default\"), title, description, labels, annotations, tags (string array), links.\n\nEntity references use the format \"kind:namespace/name\", e.g. \"component:default/my-service\" or \"user:default/jane.doe\".\n\n### Entity Kinds\n\n**Component** - A piece of software such as a service, website, or library.\n spec fields: type (e.g. \"service\", \"website\", \"library\"), lifecycle (e.g. \"production\", \"experimental\", \"deprecated\"), owner (entity ref), system, subcomponentOf, providesApis, consumesApis, dependsOn, dependencyOf.\n\n**API** - An interface that components expose, such as REST APIs or event streams.\n spec fields: type (e.g. \"openapi\", \"asyncapi\", \"graphql\", \"grpc\"), lifecycle, owner (entity ref), definition (the API spec content), system.\n\n**System** - A collection of components, APIs, and resources that together expose some functionality.\n spec fields: owner (entity ref), domain, type.\n\n**Domain** - A grouping of systems that share terminology, domain models, and business purpose.\n spec fields: owner (entity ref), subdomainOf, type.\n\n**Resource** - Infrastructure required to operate a component, such as databases or storage buckets.\n spec fields: type, owner (entity ref), system, dependsOn, dependencyOf.\n\n**Group** - An organizational entity such as a team or business unit.\n spec fields: type (e.g. \"team\", \"business-unit\"), children (entity refs), parent (entity ref), members (entity refs), profile (displayName, email, picture).\n\n**User** - A person, such as an employee or contractor.\n spec fields: memberOf (entity refs), profile (displayName, email, picture).\n\n**Location** - A marker that references other catalog descriptor files to be ingested.\n spec fields: type, target, targets, presence.\n\n### Relations\n\nEntities have bidirectional relations stored in the \"relations\" array. Common relation types: ownedBy/ownerOf, dependsOn/dependencyOf, providesApi/apiProvidedBy, consumesApi/apiConsumedBy, parentOf/childOf, memberOf/hasMember, partOf/hasPart.\n\nRelations can be queried via \"relations.<type>\" e.g. \"relations.ownedby: user:default/jane-doe\". The value there must always be a valid entity reference.\n\nWhen querying for entity relationships, prefer using relations over spec fields. For example, use \"relations.ownedby\" instead of \"spec.owner\" to find entities owned by a particular group or user.\n${QUERY_SYNTAX}`;\n\nconst MODEL_REFERENCE_DESCRIPTION = `\nQuery entities from the Backstage Software Catalog using predicate filters.\n\nFor a complete list of entity kinds, fields, relations, and other queryable attributes available in the catalog, use \\`get-catalog-model-description\\`.\n${QUERY_SYNTAX}`;\n\nexport const createQueryCatalogEntitiesAction = ({\n catalog,\n actionsRegistry,\n useExperimentalCatalogLayersDescriptions,\n}: {\n catalog: CatalogService;\n actionsRegistry: ActionsRegistryService;\n useExperimentalCatalogLayersDescriptions?: boolean;\n}) => {\n actionsRegistry.register({\n name: 'query-catalog-entities',\n title: 'Query Catalog Entities',\n attributes: {\n destructive: false,\n readOnly: true,\n idempotent: true,\n },\n description: useExperimentalCatalogLayersDescriptions\n ? MODEL_REFERENCE_DESCRIPTION\n : INLINE_MODEL_DESCRIPTION,\n schema: {\n input: z =>\n z.object({\n query: createZodV3FilterPredicateSchema(z)\n .optional()\n .describe(\n 'Entity predicate query. Supports field matching, $all, $any, $not, $exists, $in, $contains, and $hasPrefix operators.',\n ),\n fields: z\n .array(z.string())\n .optional()\n .describe(\n 'Specific fields to include in the response. If not provided, all fields are returned. Each entry is a dot separated path into an entity, e.g. `spec.type`.',\n ),\n limit: z\n .number()\n .int()\n .positive()\n .optional()\n .describe('Maximum number of entities to return at a time.'),\n offset: z\n .number()\n .int()\n .min(0)\n .optional()\n .describe('Number of entities to skip before returning results.'),\n orderFields: z\n .union([\n z.object({\n field: z\n .string()\n .describe(\n 'Field to order by. The format is a dot separated path into an entity, e.g. `spec.type`.',\n ),\n order: z.enum(['asc', 'desc']).describe('Sort order'),\n }),\n z.array(\n z.object({\n field: z\n .string()\n .describe(\n 'Field to order by. The format is a dot separated path into an entity, e.g. `spec.type`.',\n ),\n order: z.enum(['asc', 'desc']).describe('Sort order'),\n }),\n ),\n ])\n .optional()\n .describe(\n 'Ordering criteria for the results. Can be a single order directive or an array for multi-field sorting.',\n ),\n fullTextFilter: z\n .object({\n term: z.string().describe('Full text search term'),\n fields: z\n .array(z.string())\n .optional()\n .describe(\n 'Fields to search within. Each entry is a dot separated path into an entity, e.g. `spec.type`.',\n ),\n })\n .optional()\n .describe('Full text search criteria'),\n cursor: z\n .string()\n .optional()\n .describe(\n 'Cursor for pagination. This can be used only after the first request with a response containing a cursor. If a cursor is given it takes precedence over `offset`.',\n ),\n }),\n output: z =>\n z.object({\n items: z\n .array(z.object({}).passthrough())\n .describe('List of entities'),\n totalItems: z.number().describe('Total number of entities'),\n hasMoreEntities: z\n .boolean()\n .describe('Whether more entities are available'),\n nextPageCursor: z\n .string()\n .optional()\n .describe('Next page cursor used to fetch next page of entities'),\n }),\n },\n action: async ({ input, credentials }) => {\n const response = await catalog.queryEntities(\n {\n ...input,\n query: input.query,\n },\n { credentials },\n );\n\n return {\n output: {\n items: response.items,\n totalItems: response.totalItems,\n hasMoreEntities: !!response.pageInfo.nextCursor,\n nextPageCursor: response.pageInfo.nextCursor,\n },\n };\n },\n });\n};\n"],"names":["createZodV3FilterPredicateSchema"],"mappings":"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"}
@@ -31,10 +31,10 @@ Once completed, all entities associated with the Location will be deleted from t
31
31
  `URL of the catalog-info.yaml file to unregister for example: https://github.com/backstage/demo/blob/master/catalog-info.yaml`
32
32
  )
33
33
  })
34
- ])
35
- }).describe(
36
- "The type to the unregister-entity action. Either locationId or locationUrl must be provided."
37
- ),
34
+ ]).describe(
35
+ "Identifies the entity to unregister. Provide either locationId or locationUrl."
36
+ )
37
+ }),
38
38
  output: (z) => z.object({})
39
39
  },
40
40
  action: async ({ input: { type }, credentials }) => {
@@ -1 +1 @@
1
- {"version":3,"file":"createUnregisterCatalogEntitiesAction.cjs.js","sources":["../../src/actions/createUnregisterCatalogEntitiesAction.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { NotFoundError } from '@backstage/errors';\nimport { CatalogService } from '@backstage/plugin-catalog-node';\n\nexport const createUnregisterCatalogEntitiesAction = ({\n catalog,\n actionsRegistry,\n}: {\n catalog: CatalogService;\n actionsRegistry: ActionsRegistryService;\n}) => {\n actionsRegistry.register({\n name: 'unregister-entity',\n title: 'Unregister entity from the Catalog',\n attributes: {\n destructive: true,\n readOnly: false,\n idempotent: true,\n },\n description: `Unregisters a Location entity and all entities it owns from the Backstage catalog.\n\nThis action is similar to the \"Unregister location\" function in the Backstage UI, where you provide the unique identifier (locationId) of a Location entity. Alternatively, you can provide the URL used to register the location. The action will remove the specified Location from the catalog as well as all entities that were created when the Location was imported.\n\nOnce completed, all entities associated with the Location will be deleted from the catalog.\n`,\n schema: {\n input: z =>\n z\n .object({\n type: z.union([\n z.object({\n locationId: z\n .string()\n .describe(`Location ID of the Entity to unregister`),\n }),\n z.object({\n locationUrl: z\n .string()\n .describe(\n `URL of the catalog-info.yaml file to unregister for example: https://github.com/backstage/demo/blob/master/catalog-info.yaml`,\n ),\n }),\n ]),\n })\n .describe(\n 'The type to the unregister-entity action. Either locationId or locationUrl must be provided.',\n ),\n output: z => z.object({}),\n },\n action: async ({ input: { type }, credentials }) => {\n if ('locationId' in type) {\n await catalog.removeLocationById(type.locationId, {\n credentials,\n });\n } else {\n const locations = await catalog\n .getLocations(\n {},\n {\n credentials,\n },\n )\n .then(response =>\n response.items.filter(\n location =>\n location.target.toLowerCase() ===\n type.locationUrl.toLowerCase(),\n ),\n );\n\n if (locations.length === 0) {\n throw new NotFoundError(\n `Location with URL ${type.locationUrl} not found`,\n );\n }\n\n for (const location of locations) {\n await catalog.removeLocationById(location.id, {\n credentials,\n });\n }\n }\n\n return { output: {} };\n },\n });\n};\n"],"names":["NotFoundError"],"mappings":";;;;AAmBO,MAAM,wCAAwC,CAAC;AAAA,EACpD,OAAA;AAAA,EACA;AACF,CAAA,KAGM;AACJ,EAAA,eAAA,CAAgB,QAAA,CAAS;AAAA,IACvB,IAAA,EAAM,mBAAA;AAAA,IACN,KAAA,EAAO,oCAAA;AAAA,IACP,UAAA,EAAY;AAAA,MACV,WAAA,EAAa,IAAA;AAAA,MACb,QAAA,EAAU,KAAA;AAAA,MACV,UAAA,EAAY;AAAA,KACd;AAAA,IACA,WAAA,EAAa,CAAA;;AAAA;;AAAA;AAAA,CAAA;AAAA,IAMb,MAAA,EAAQ;AAAA,MACN,KAAA,EAAO,CAAA,CAAA,KACL,CAAA,CACG,MAAA,CAAO;AAAA,QACN,IAAA,EAAM,EAAE,KAAA,CAAM;AAAA,UACZ,EAAE,MAAA,CAAO;AAAA,YACP,UAAA,EAAY,CAAA,CACT,MAAA,EAAO,CACP,SAAS,CAAA,uCAAA,CAAyC;AAAA,WACtD,CAAA;AAAA,UACD,EAAE,MAAA,CAAO;AAAA,YACP,WAAA,EAAa,CAAA,CACV,MAAA,EAAO,CACP,QAAA;AAAA,cACC,CAAA,4HAAA;AAAA;AACF,WACH;AAAA,SACF;AAAA,OACF,CAAA,CACA,QAAA;AAAA,QACC;AAAA,OACF;AAAA,MACJ,MAAA,EAAQ,CAAA,CAAA,KAAK,CAAA,CAAE,MAAA,CAAO,EAAE;AAAA,KAC1B;AAAA,IACA,MAAA,EAAQ,OAAO,EAAE,KAAA,EAAO,EAAE,IAAA,EAAK,EAAG,aAAY,KAAM;AAClD,MAAA,IAAI,gBAAgB,IAAA,EAAM;AACxB,QAAA,MAAM,OAAA,CAAQ,kBAAA,CAAmB,IAAA,CAAK,UAAA,EAAY;AAAA,UAChD;AAAA,SACD,CAAA;AAAA,MACH,CAAA,MAAO;AACL,QAAA,MAAM,SAAA,GAAY,MAAM,OAAA,CACrB,YAAA;AAAA,UACC,EAAC;AAAA,UACD;AAAA,YACE;AAAA;AACF,SACF,CACC,IAAA;AAAA,UAAK,CAAA,QAAA,KACJ,SAAS,KAAA,CAAM,MAAA;AAAA,YACb,cACE,QAAA,CAAS,MAAA,CAAO,aAAY,KAC5B,IAAA,CAAK,YAAY,WAAA;AAAY;AACjC,SACF;AAEF,QAAA,IAAI,SAAA,CAAU,WAAW,CAAA,EAAG;AAC1B,UAAA,MAAM,IAAIA,oBAAA;AAAA,YACR,CAAA,kBAAA,EAAqB,KAAK,WAAW,CAAA,UAAA;AAAA,WACvC;AAAA,QACF;AAEA,QAAA,KAAA,MAAW,YAAY,SAAA,EAAW;AAChC,UAAA,MAAM,OAAA,CAAQ,kBAAA,CAAmB,QAAA,CAAS,EAAA,EAAI;AAAA,YAC5C;AAAA,WACD,CAAA;AAAA,QACH;AAAA,MACF;AAEA,MAAA,OAAO,EAAE,MAAA,EAAQ,EAAC,EAAE;AAAA,IACtB;AAAA,GACD,CAAA;AACH;;;;"}
1
+ {"version":3,"file":"createUnregisterCatalogEntitiesAction.cjs.js","sources":["../../src/actions/createUnregisterCatalogEntitiesAction.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { NotFoundError } from '@backstage/errors';\nimport { CatalogService } from '@backstage/plugin-catalog-node';\n\nexport const createUnregisterCatalogEntitiesAction = ({\n catalog,\n actionsRegistry,\n}: {\n catalog: CatalogService;\n actionsRegistry: ActionsRegistryService;\n}) => {\n actionsRegistry.register({\n name: 'unregister-entity',\n title: 'Unregister entity from the Catalog',\n attributes: {\n destructive: true,\n readOnly: false,\n idempotent: true,\n },\n description: `Unregisters a Location entity and all entities it owns from the Backstage catalog.\n\nThis action is similar to the \"Unregister location\" function in the Backstage UI, where you provide the unique identifier (locationId) of a Location entity. Alternatively, you can provide the URL used to register the location. The action will remove the specified Location from the catalog as well as all entities that were created when the Location was imported.\n\nOnce completed, all entities associated with the Location will be deleted from the catalog.\n`,\n schema: {\n input: z =>\n z.object({\n type: z\n .union([\n z.object({\n locationId: z\n .string()\n .describe(`Location ID of the Entity to unregister`),\n }),\n z.object({\n locationUrl: z\n .string()\n .describe(\n `URL of the catalog-info.yaml file to unregister for example: https://github.com/backstage/demo/blob/master/catalog-info.yaml`,\n ),\n }),\n ])\n .describe(\n 'Identifies the entity to unregister. Provide either locationId or locationUrl.',\n ),\n }),\n output: z => z.object({}),\n },\n action: async ({ input: { type }, credentials }) => {\n if ('locationId' in type) {\n await catalog.removeLocationById(type.locationId, {\n credentials,\n });\n } else {\n const locations = await catalog\n .getLocations(\n {},\n {\n credentials,\n },\n )\n .then(response =>\n response.items.filter(\n location =>\n location.target.toLowerCase() ===\n type.locationUrl.toLowerCase(),\n ),\n );\n\n if (locations.length === 0) {\n throw new NotFoundError(\n `Location with URL ${type.locationUrl} not found`,\n );\n }\n\n for (const location of locations) {\n await catalog.removeLocationById(location.id, {\n credentials,\n });\n }\n }\n\n return { output: {} };\n },\n });\n};\n"],"names":["NotFoundError"],"mappings":";;;;AAmBO,MAAM,wCAAwC,CAAC;AAAA,EACpD,OAAA;AAAA,EACA;AACF,CAAA,KAGM;AACJ,EAAA,eAAA,CAAgB,QAAA,CAAS;AAAA,IACvB,IAAA,EAAM,mBAAA;AAAA,IACN,KAAA,EAAO,oCAAA;AAAA,IACP,UAAA,EAAY;AAAA,MACV,WAAA,EAAa,IAAA;AAAA,MACb,QAAA,EAAU,KAAA;AAAA,MACV,UAAA,EAAY;AAAA,KACd;AAAA,IACA,WAAA,EAAa,CAAA;;AAAA;;AAAA;AAAA,CAAA;AAAA,IAMb,MAAA,EAAQ;AAAA,MACN,KAAA,EAAO,CAAA,CAAA,KACL,CAAA,CAAE,MAAA,CAAO;AAAA,QACP,IAAA,EAAM,EACH,KAAA,CAAM;AAAA,UACL,EAAE,MAAA,CAAO;AAAA,YACP,UAAA,EAAY,CAAA,CACT,MAAA,EAAO,CACP,SAAS,CAAA,uCAAA,CAAyC;AAAA,WACtD,CAAA;AAAA,UACD,EAAE,MAAA,CAAO;AAAA,YACP,WAAA,EAAa,CAAA,CACV,MAAA,EAAO,CACP,QAAA;AAAA,cACC,CAAA,4HAAA;AAAA;AACF,WACH;AAAA,SACF,CAAA,CACA,QAAA;AAAA,UACC;AAAA;AACF,OACH,CAAA;AAAA,MACH,MAAA,EAAQ,CAAA,CAAA,KAAK,CAAA,CAAE,MAAA,CAAO,EAAE;AAAA,KAC1B;AAAA,IACA,MAAA,EAAQ,OAAO,EAAE,KAAA,EAAO,EAAE,IAAA,EAAK,EAAG,aAAY,KAAM;AAClD,MAAA,IAAI,gBAAgB,IAAA,EAAM;AACxB,QAAA,MAAM,OAAA,CAAQ,kBAAA,CAAmB,IAAA,CAAK,UAAA,EAAY;AAAA,UAChD;AAAA,SACD,CAAA;AAAA,MACH,CAAA,MAAO;AACL,QAAA,MAAM,SAAA,GAAY,MAAM,OAAA,CACrB,YAAA;AAAA,UACC,EAAC;AAAA,UACD;AAAA,YACE;AAAA;AACF,SACF,CACC,IAAA;AAAA,UAAK,CAAA,QAAA,KACJ,SAAS,KAAA,CAAM,MAAA;AAAA,YACb,cACE,QAAA,CAAS,MAAA,CAAO,aAAY,KAC5B,IAAA,CAAK,YAAY,WAAA;AAAY;AACjC,SACF;AAEF,QAAA,IAAI,SAAA,CAAU,WAAW,CAAA,EAAG;AAC1B,UAAA,MAAM,IAAIA,oBAAA;AAAA,YACR,CAAA,kBAAA,EAAqB,KAAK,WAAW,CAAA,UAAA;AAAA,WACvC;AAAA,QACF;AAEA,QAAA,KAAA,MAAW,YAAY,SAAA,EAAW;AAChC,UAAA,MAAM,OAAA,CAAQ,kBAAA,CAAmB,QAAA,CAAS,EAAA,EAAI;AAAA,YAC5C;AAAA,WACD,CAAA;AAAA,QACH;AAAA,MACF;AAEA,MAAA,OAAO,EAAE,MAAA,EAAQ,EAAC,EAAE;AAAA,IACtB;AAAA,GACD,CAAA;AACH;;;;"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs.js","sources":["../../src/actions/index.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { CatalogService } from '@backstage/plugin-catalog-node';\nimport { ModelHolder } from '../model/ModelHolder';\nimport { createGetCatalogModelDescriptionAction } from './createGetCatalogModelDescriptionAction.ts';\nimport { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts';\nimport { createValidateEntityAction } from './createValidateEntityAction.ts';\nimport { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction.ts';\nimport { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction.ts';\nimport { createQueryCatalogEntitiesAction } from './createQueryCatalogEntitiesAction.ts';\n\nexport const createCatalogActions = (options: {\n actionsRegistry: ActionsRegistryService;\n catalog: CatalogService;\n modelHolder: ModelHolder | undefined;\n}) => {\n createGetCatalogModelDescriptionAction(options);\n createGetCatalogEntityAction(options);\n createValidateEntityAction(options);\n createRegisterCatalogEntitiesAction(options);\n createUnregisterCatalogEntitiesAction(options);\n createQueryCatalogEntitiesAction(options);\n};\n"],"names":["createGetCatalogModelDescriptionAction","createGetCatalogEntityAction","createValidateEntityAction","createRegisterCatalogEntitiesAction","createUnregisterCatalogEntitiesAction","createQueryCatalogEntitiesAction"],"mappings":";;;;;;;;;AA0BO,MAAM,oBAAA,GAAuB,CAAC,OAAA,KAI/B;AACJ,EAAAA,6EAAA,CAAuC,OAAO,CAAA;AAC9C,EAAAC,yDAAA,CAA6B,OAAO,CAAA;AACpC,EAAAC,qDAAA,CAA2B,OAAO,CAAA;AAClC,EAAAC,uEAAA,CAAoC,OAAO,CAAA;AAC3C,EAAAC,2EAAA,CAAsC,OAAO,CAAA;AAC7C,EAAAC,iEAAA,CAAiC,OAAO,CAAA;AAC1C;;;;"}
1
+ {"version":3,"file":"index.cjs.js","sources":["../../src/actions/index.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { CatalogService } from '@backstage/plugin-catalog-node';\nimport { ModelHolder } from '../model/ModelHolder';\nimport { createGetCatalogModelDescriptionAction } from './createGetCatalogModelDescriptionAction.ts';\nimport { createGetCatalogEntityAction } from './createGetCatalogEntityAction.ts';\nimport { createValidateEntityAction } from './createValidateEntityAction.ts';\nimport { createRegisterCatalogEntitiesAction } from './createRegisterCatalogEntitiesAction.ts';\nimport { createUnregisterCatalogEntitiesAction } from './createUnregisterCatalogEntitiesAction.ts';\nimport { createQueryCatalogEntitiesAction } from './createQueryCatalogEntitiesAction.ts';\n\nexport const createCatalogActions = (options: {\n actionsRegistry: ActionsRegistryService;\n catalog: CatalogService;\n modelHolder: ModelHolder | undefined;\n useExperimentalCatalogLayersDescriptions?: boolean;\n}) => {\n createGetCatalogModelDescriptionAction(options);\n createGetCatalogEntityAction(options);\n createValidateEntityAction(options);\n createRegisterCatalogEntitiesAction(options);\n createUnregisterCatalogEntitiesAction(options);\n createQueryCatalogEntitiesAction(options);\n};\n"],"names":["createGetCatalogModelDescriptionAction","createGetCatalogEntityAction","createValidateEntityAction","createRegisterCatalogEntitiesAction","createUnregisterCatalogEntitiesAction","createQueryCatalogEntitiesAction"],"mappings":";;;;;;;;;AA0BO,MAAM,oBAAA,GAAuB,CAAC,OAAA,KAK/B;AACJ,EAAAA,6EAAA,CAAuC,OAAO,CAAA;AAC9C,EAAAC,yDAAA,CAA6B,OAAO,CAAA;AACpC,EAAAC,qDAAA,CAA2B,OAAO,CAAA;AAClC,EAAAC,uEAAA,CAAoC,OAAO,CAAA;AAC3C,EAAAC,2EAAA,CAAsC,OAAO,CAAA;AAC7C,EAAAC,iEAAA,CAAiC,OAAO,CAAA;AAC1C;;;;"}
@@ -2,26 +2,68 @@
2
2
 
3
3
  var metrics = require('../util/metrics.cjs.js');
4
4
 
5
+ const ENTITIES_COUNT_TTL_MS = 3e4;
6
+ function createEntitiesCountByKind(knex, options) {
7
+ const ttlMs = ENTITIES_COUNT_TTL_MS;
8
+ let inflight;
9
+ let cached;
10
+ return () => {
11
+ if (inflight) {
12
+ return inflight;
13
+ }
14
+ if (cached && Date.now() - cached.at < ttlMs) {
15
+ return Promise.resolve(cached.data);
16
+ }
17
+ inflight = (async () => {
18
+ try {
19
+ const data = await queryEntitiesCountByKind(knex);
20
+ cached = { at: Date.now(), data };
21
+ return data;
22
+ } finally {
23
+ inflight = void 0;
24
+ }
25
+ })();
26
+ return inflight;
27
+ };
28
+ }
29
+ async function queryEntitiesCountByKind(knex) {
30
+ const kindExpr = entityRefKindExpression(knex);
31
+ const rows = await knex(
32
+ "final_entities"
33
+ ).whereNotNull("final_entity").select({ kind: kindExpr, count: knex.raw("count(*)") }).groupBy(kindExpr);
34
+ return new Map(rows.map((row) => [String(row.kind), Number(row.count)]));
35
+ }
36
+ function entityRefKindExpression(knex) {
37
+ const client = knex.client.config.client;
38
+ if (client.includes("pg")) {
39
+ return knex.raw(`split_part(entity_ref, ':', 1)`);
40
+ }
41
+ if (client.includes("mysql")) {
42
+ return knex.raw(`substring_index(entity_ref, ':', 1)`);
43
+ }
44
+ return knex.raw(`substr(entity_ref, 1, instr(entity_ref, ':') - 1)`);
45
+ }
5
46
  function initDatabaseMetrics(knex, metrics$1) {
6
47
  const seenProm = /* @__PURE__ */ new Set();
7
48
  const seen = /* @__PURE__ */ new Set();
49
+ const getEntitiesCountByKind = createEntitiesCountByKind(knex);
8
50
  return {
9
51
  entities_count_prom: metrics.createGaugeMetric({
10
52
  name: "catalog_entities_count",
11
53
  help: "Total amount of entities in the catalog. DEPRECATED: Please use opentelemetry metrics instead.",
12
54
  labelNames: ["kind"],
13
55
  async collect() {
14
- const results = await knex("search").where("key", "=", "kind").whereNotNull("value").select({ kind: "value", count: knex.raw("count(*)") }).groupBy("value");
15
- results.forEach(({ kind, count }) => {
56
+ const results = await getEntitiesCountByKind();
57
+ for (const [kind, count] of results) {
16
58
  seenProm.add(kind);
17
- this.set({ kind }, Number(count));
18
- });
19
- seenProm.forEach((kind) => {
20
- if (!results.some((r) => r.kind === kind)) {
59
+ this.set({ kind }, count);
60
+ }
61
+ for (const kind of seenProm) {
62
+ if (!results.has(kind)) {
21
63
  this.set({ kind }, 0);
22
64
  seenProm.delete(kind);
23
65
  }
24
- });
66
+ }
25
67
  }
26
68
  }),
27
69
  registered_locations_prom: metrics.createGaugeMetric({
@@ -47,17 +89,17 @@ function initDatabaseMetrics(knex, metrics$1) {
47
89
  entities_count: metrics$1.createObservableGauge("catalog_entities_count", {
48
90
  description: "Total amount of entities in the catalog"
49
91
  }).addCallback(async (gauge) => {
50
- const results = await knex("search").where("key", "=", "kind").whereNotNull("value").select({ kind: "value", count: knex.raw("count(*)") }).groupBy("value");
51
- results.forEach(({ kind, count }) => {
92
+ const results = await getEntitiesCountByKind();
93
+ for (const [kind, count] of results) {
52
94
  seen.add(kind);
53
- gauge.observe(Number(count), { kind });
54
- });
55
- seen.forEach((kind) => {
56
- if (!results.some((r) => r.kind === kind)) {
95
+ gauge.observe(count, { kind });
96
+ }
97
+ for (const kind of seen) {
98
+ if (!results.has(kind)) {
57
99
  gauge.observe(0, { kind });
58
100
  seen.delete(kind);
59
101
  }
60
- });
102
+ }
61
103
  }),
62
104
  registered_locations: metrics$1.createObservableGauge("catalog_registered_locations_count", {
63
105
  description: "Total amount of registered locations in the catalog"
@@ -96,5 +138,7 @@ function initDatabaseMetrics(knex, metrics$1) {
96
138
  };
97
139
  }
98
140
 
141
+ exports.createEntitiesCountByKind = createEntitiesCountByKind;
99
142
  exports.initDatabaseMetrics = initDatabaseMetrics;
143
+ exports.queryEntitiesCountByKind = queryEntitiesCountByKind;
100
144
  //# sourceMappingURL=metrics.cjs.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"metrics.cjs.js","sources":["../../src/database/metrics.ts"],"sourcesContent":["/*\n * Copyright 2021 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Knex } from 'knex';\nimport { createGaugeMetric } from '../util/metrics';\nimport { DbRelationsRow, DbLocationsRow, DbSearchRow } from './tables';\nimport { MetricsService } from '@backstage/backend-plugin-api/alpha';\n\nexport function initDatabaseMetrics(knex: Knex, metrics: MetricsService) {\n const seenProm = new Set<string>();\n const seen = new Set<string>();\n\n return {\n entities_count_prom: createGaugeMetric({\n name: 'catalog_entities_count',\n help: 'Total amount of entities in the catalog. DEPRECATED: Please use opentelemetry metrics instead.',\n labelNames: ['kind'],\n async collect() {\n const results = await knex<DbSearchRow>('search')\n .where('key', '=', 'kind')\n .whereNotNull('value')\n .select({ kind: 'value', count: knex.raw('count(*)') })\n .groupBy('value');\n\n results.forEach(({ kind, count }) => {\n seenProm.add(kind);\n this.set({ kind }, Number(count));\n });\n\n // Set all the entities that were not seenProm to 0 and delete them from the seenProm set.\n seenProm.forEach(kind => {\n if (!results.some(r => r.kind === kind)) {\n this.set({ kind }, 0);\n seenProm.delete(kind);\n }\n });\n },\n }),\n registered_locations_prom: createGaugeMetric({\n name: 'catalog_registered_locations_count',\n help: 'Total amount of registered locations in the catalog. DEPRECATED: Please use opentelemetry metrics instead.',\n async collect() {\n const total = await knex<DbLocationsRow>('locations').count({\n count: '*',\n });\n this.set(Number(total[0].count));\n },\n }),\n relations_prom: createGaugeMetric({\n name: 'catalog_relations_count',\n help: 'Total amount of relations between entities. DEPRECATED: Please use opentelemetry metrics instead.',\n async collect() {\n const total = await knex<DbRelationsRow>('relations').count({\n count: '*',\n });\n this.set(Number(total[0].count));\n },\n }),\n entities_count: metrics\n .createObservableGauge('catalog_entities_count', {\n description: 'Total amount of entities in the catalog',\n })\n .addCallback(async gauge => {\n const results = await knex<DbSearchRow>('search')\n .where('key', '=', 'kind')\n .whereNotNull('value')\n .select({ kind: 'value', count: knex.raw('count(*)') })\n .groupBy('value');\n\n results.forEach(({ kind, count }) => {\n seen.add(kind);\n gauge.observe(Number(count), { kind });\n });\n\n // Set all the entities that were not seen to 0 and delete them from the seen set.\n seen.forEach(kind => {\n if (!results.some(r => r.kind === kind)) {\n gauge.observe(0, { kind });\n seen.delete(kind);\n }\n });\n }),\n registered_locations: metrics\n .createObservableGauge('catalog_registered_locations_count', {\n description: 'Total amount of registered locations in the catalog',\n })\n .addCallback(async gauge => {\n if (knex.client.config.client === 'pg') {\n // https://stackoverflow.com/questions/7943233/fast-way-to-discover-the-row-count-of-a-table-in-postgresql\n const total = await knex.raw(`\n SELECT reltuples::bigint AS estimate\n FROM pg_class\n WHERE oid = 'locations'::regclass;\n `);\n gauge.observe(Number(total.rows[0].estimate));\n } else {\n const total = await knex<DbLocationsRow>('locations').count({\n count: '*',\n });\n gauge.observe(Number(total[0].count));\n }\n }),\n relations: metrics\n .createObservableGauge('catalog_relations_count', {\n description: 'Total amount of relations between entities',\n })\n .addCallback(async gauge => {\n if (knex.client.config.client === 'pg') {\n // https://stackoverflow.com/questions/7943233/fast-way-to-discover-the-row-count-of-a-table-in-postgresql\n const total = await knex.raw(`\n SELECT reltuples::bigint AS estimate\n FROM pg_class\n WHERE oid = 'relations'::regclass;\n `);\n gauge.observe(Number(total.rows[0].estimate));\n } else {\n const total = await knex<DbRelationsRow>('relations').count({\n count: '*',\n });\n gauge.observe(Number(total[0].count));\n }\n }),\n };\n}\n"],"names":["metrics","createGaugeMetric"],"mappings":"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"}
1
+ {"version":3,"file":"metrics.cjs.js","sources":["../../src/database/metrics.ts"],"sourcesContent":["/*\n * Copyright 2021 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Knex } from 'knex';\nimport { createGaugeMetric } from '../util/metrics';\nimport { DbRelationsRow, DbLocationsRow } from './tables';\nimport { MetricsService } from '@backstage/backend-plugin-api/alpha';\n\nconst ENTITIES_COUNT_TTL_MS = 30_000;\n\n/**\n * Returns a function that produces a Map of entity kind -> count, with\n * a single-flight cache that coalesces overlapping calls.\n *\n * The OpenTelemetry observable gauge and the legacy Prometheus gauge are\n * both registered to emit the same `catalog_entities_count` series, and\n * both fire on every metrics scrape. Without coalescing, that means two\n * identical heavy queries per scrape per pod, which can pile up against\n * the database faster than they complete.\n *\n * Concurrent callers share the same in-flight promise, so a query is\n * never overlapped by another instance of itself. The TTL is the\n * minimum age at which the next caller is allowed to start a fresh\n * query; if a query is still running when the TTL would have elapsed,\n * waiting callers continue to share that one rather than starting a\n * duplicate.\n *\n * @internal exported for testing\n */\nexport function createEntitiesCountByKind(\n knex: Knex,\n options?: { ttlMs?: number },\n): () => Promise<Map<string, number>> {\n const ttlMs = options?.ttlMs ?? ENTITIES_COUNT_TTL_MS;\n let inflight: Promise<Map<string, number>> | undefined;\n let cached: { at: number; data: Map<string, number> } | undefined;\n return () => {\n if (inflight) {\n return inflight;\n }\n if (cached && Date.now() - cached.at < ttlMs) {\n return Promise.resolve(cached.data);\n }\n inflight = (async () => {\n try {\n const data = await queryEntitiesCountByKind(knex);\n cached = { at: Date.now(), data };\n return data;\n } finally {\n inflight = undefined;\n }\n })();\n return inflight;\n };\n}\n\n/**\n * Reads kind counts straight from `final_entities` (one row per entity)\n * rather than from `search` (one row per entity per indexed key, often\n * 20-30x larger). The kind is parsed out of `entity_ref`, which is the\n * canonical lowercased `kind:namespace/name` form -- producing the same\n * lowercased labels the previous search-based query did.\n *\n * Rows where `final_entity` is null are excluded. They represent entities\n * that haven't been stitched yet, or tombstones for in-progress deletions.\n * Counting them would over-report by including entities the rest of the\n * catalog API treats as not present.\n *\n * @internal exported for testing\n */\nexport async function queryEntitiesCountByKind(\n knex: Knex,\n): Promise<Map<string, number>> {\n const kindExpr = entityRefKindExpression(knex);\n\n const rows: { kind: string; count: string | number }[] = await knex(\n 'final_entities',\n )\n .whereNotNull('final_entity')\n .select({ kind: kindExpr, count: knex.raw('count(*)') })\n .groupBy(kindExpr);\n\n return new Map(rows.map(row => [String(row.kind), Number(row.count)]));\n}\n\nfunction entityRefKindExpression(knex: Knex): Knex.Raw {\n const client = knex.client.config.client as string;\n if (client.includes('pg')) {\n return knex.raw(`split_part(entity_ref, ':', 1)`);\n }\n if (client.includes('mysql')) {\n return knex.raw(`substring_index(entity_ref, ':', 1)`);\n }\n // sqlite (better-sqlite3, sqlite3)\n return knex.raw(`substr(entity_ref, 1, instr(entity_ref, ':') - 1)`);\n}\n\nexport function initDatabaseMetrics(knex: Knex, metrics: MetricsService) {\n const seenProm = new Set<string>();\n const seen = new Set<string>();\n const getEntitiesCountByKind = createEntitiesCountByKind(knex);\n\n return {\n entities_count_prom: createGaugeMetric({\n name: 'catalog_entities_count',\n help: 'Total amount of entities in the catalog. DEPRECATED: Please use opentelemetry metrics instead.',\n labelNames: ['kind'],\n async collect() {\n const results = await getEntitiesCountByKind();\n\n for (const [kind, count] of results) {\n seenProm.add(kind);\n this.set({ kind }, count);\n }\n\n // Set all the entities that were not seenProm to 0 and delete them from the seenProm set.\n for (const kind of seenProm) {\n if (!results.has(kind)) {\n this.set({ kind }, 0);\n seenProm.delete(kind);\n }\n }\n },\n }),\n registered_locations_prom: createGaugeMetric({\n name: 'catalog_registered_locations_count',\n help: 'Total amount of registered locations in the catalog. DEPRECATED: Please use opentelemetry metrics instead.',\n async collect() {\n const total = await knex<DbLocationsRow>('locations').count({\n count: '*',\n });\n this.set(Number(total[0].count));\n },\n }),\n relations_prom: createGaugeMetric({\n name: 'catalog_relations_count',\n help: 'Total amount of relations between entities. DEPRECATED: Please use opentelemetry metrics instead.',\n async collect() {\n const total = await knex<DbRelationsRow>('relations').count({\n count: '*',\n });\n this.set(Number(total[0].count));\n },\n }),\n entities_count: metrics\n .createObservableGauge('catalog_entities_count', {\n description: 'Total amount of entities in the catalog',\n })\n .addCallback(async gauge => {\n const results = await getEntitiesCountByKind();\n\n for (const [kind, count] of results) {\n seen.add(kind);\n gauge.observe(count, { kind });\n }\n\n // Set all the entities that were not seen to 0 and delete them from the seen set.\n for (const kind of seen) {\n if (!results.has(kind)) {\n gauge.observe(0, { kind });\n seen.delete(kind);\n }\n }\n }),\n registered_locations: metrics\n .createObservableGauge('catalog_registered_locations_count', {\n description: 'Total amount of registered locations in the catalog',\n })\n .addCallback(async gauge => {\n if (knex.client.config.client === 'pg') {\n // https://stackoverflow.com/questions/7943233/fast-way-to-discover-the-row-count-of-a-table-in-postgresql\n const total = await knex.raw(`\n SELECT reltuples::bigint AS estimate\n FROM pg_class\n WHERE oid = 'locations'::regclass;\n `);\n gauge.observe(Number(total.rows[0].estimate));\n } else {\n const total = await knex<DbLocationsRow>('locations').count({\n count: '*',\n });\n gauge.observe(Number(total[0].count));\n }\n }),\n relations: metrics\n .createObservableGauge('catalog_relations_count', {\n description: 'Total amount of relations between entities',\n })\n .addCallback(async gauge => {\n if (knex.client.config.client === 'pg') {\n // https://stackoverflow.com/questions/7943233/fast-way-to-discover-the-row-count-of-a-table-in-postgresql\n const total = await knex.raw(`\n SELECT reltuples::bigint AS estimate\n FROM pg_class\n WHERE oid = 'relations'::regclass;\n `);\n gauge.observe(Number(total.rows[0].estimate));\n } else {\n const total = await knex<DbRelationsRow>('relations').count({\n count: '*',\n });\n gauge.observe(Number(total[0].count));\n }\n }),\n };\n}\n"],"names":["metrics","createGaugeMetric"],"mappings":"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"}
@@ -2,6 +2,7 @@
2
2
 
3
3
  var catalogModel = require('@backstage/catalog-model');
4
4
  var errors = require('@backstage/errors');
5
+ var util = require('./util.cjs.js');
5
6
 
6
7
  const SPECIAL_KEYS = [
7
8
  "attachments",
@@ -113,7 +114,14 @@ function buildEntitySearch(entityId, entity) {
113
114
  `Entity has duplicate keys that vary only in casing, ${badKeys}`
114
115
  );
115
116
  }
116
- return mapToRows(raw, entityId);
117
+ const rows = mapToRows(raw, entityId);
118
+ const seen = /* @__PURE__ */ new Set();
119
+ return rows.filter((row) => {
120
+ const k = `${row.key}\0${row.value === null ? util.NULL_SENTINEL : row.value}`;
121
+ if (seen.has(k)) return false;
122
+ seen.add(k);
123
+ return true;
124
+ });
117
125
  }
118
126
 
119
127
  exports.buildEntitySearch = buildEntitySearch;