@backstage/plugin-catalog-backend 3.4.0-next.1 → 3.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # @backstage/plugin-catalog-backend
2
2
 
3
+ ## 3.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f1d29b4: Failures to connect catalog providers are now attributed to the module that provided the failing provider. This means that such failures will be reported as module startup failures rather than a failure to start the catalog plugin, and will therefore respect `onPluginModuleBootFailure` configuration instead.
8
+ - 34cc520: Implemented handling of events from the newly introduced alpha
9
+ `catalogScmEventsServiceRef` service, in the builtin entity providers. This
10
+ allows entities to get refreshed, and locations updated or removed, as a
11
+ response to incoming events. In its first iteration, only the GitHub module
12
+ implements such event handling however.
13
+
14
+ This is not yet enabled by default, but this fact may change in a future
15
+ release. To try it out, ensure that you have the latest catalog GitHub module
16
+ installed, and set the following in your app-config:
17
+
18
+ ```yaml
19
+ catalog:
20
+ scmEvents: true
21
+ ```
22
+
23
+ Or if you want to pick and choose from the various features:
24
+
25
+ ```yaml
26
+ catalog:
27
+ scmEvents:
28
+ # refresh (reprocess) upon events?
29
+ refresh: true
30
+ # automatically unregister locations based on events? (files deleted, repos archived, etc)
31
+ unregister: true
32
+ # automatically move locations based on events? (repo transferred, file renamed, etc)
33
+ move: true
34
+ ```
35
+
36
+ - b4e8249: Implemented the `POST /locations/by-query` endpoint which allows paginated, filtered location queries
37
+
38
+ ### Patch Changes
39
+
40
+ - cfd8103: Updated imports to use stable catalog extension points from `@backstage/plugin-catalog-node` instead of the deprecated alpha exports.
41
+ - 7455dae: Use node prefix on native imports
42
+ - 5e3ef57: Added `peerModules` metadata declaring recommended modules for cross-plugin integrations.
43
+ - 08a5813: Fixed O(n²) performance bottleneck in `buildEntitySearch` `traverse()` by replacing `Array.some()` linear scan with a `Set` for O(1) duplicate path key detection.
44
+ - 1e669cc: Migrate audit events reference docs to http://backstage.io/docs.
45
+ - 69d880e: Bump to latest zod to ensure it has the latest features
46
+ - Updated dependencies
47
+ - @backstage/integration@1.20.0
48
+ - @backstage/plugin-catalog-node@2.0.0
49
+ - @backstage/backend-openapi-utils@0.6.6
50
+ - @backstage/backend-plugin-api@1.7.0
51
+ - @backstage/catalog-client@1.13.0
52
+ - @backstage/filter-predicates@0.1.0
53
+ - @backstage/plugin-permission-common@0.9.6
54
+ - @backstage/plugin-permission-node@0.10.10
55
+ - @backstage/plugin-catalog-common@1.1.8
56
+ - @backstage/plugin-events-node@0.4.19
57
+
58
+ ## 3.4.0-next.2
59
+
60
+ ### Patch Changes
61
+
62
+ - 08a5813: Fixed O(n²) performance bottleneck in `buildEntitySearch` `traverse()` by replacing `Array.some()` linear scan with a `Set` for O(1) duplicate path key detection.
63
+ - Updated dependencies
64
+ - @backstage/integration@1.20.0-next.2
65
+ - @backstage/plugin-catalog-node@2.0.0-next.1
66
+ - @backstage/catalog-client@1.12.2-next.0
67
+ - @backstage/backend-plugin-api@1.7.0-next.1
68
+ - @backstage/plugin-events-node@0.4.19-next.0
69
+ - @backstage/plugin-permission-node@0.10.10-next.0
70
+
3
71
  ## 3.4.0-next.1
4
72
 
5
73
  ### Patch Changes
package/config.d.ts CHANGED
@@ -257,5 +257,48 @@ export interface Config {
257
257
  priority?: number;
258
258
  };
259
259
  };
260
+
261
+ /**
262
+ * Settings that control what to do when receiving messages from the SCM
263
+ * events service.
264
+ *
265
+ * @defaultValue false
266
+ * @remarks
267
+ *
268
+ * This is primarily meant to affect builtin providers in the catalog
269
+ * backend such as the location handler, but other providers and processors
270
+ * may also read this configuration.
271
+ *
272
+ * If set to false, disable all handling of SCM events.
273
+ *
274
+ * If set to true, enable all default handling of SCM events. Note that the
275
+ * set of default handling can change over time.
276
+ *
277
+ * You can also configure individual handlers one by one.
278
+ */
279
+ scmEvents?:
280
+ | boolean
281
+ | {
282
+ /**
283
+ * Trigger refreshes (reprocessing) of entities that are affected by an
284
+ * SCM event. This may include source control file content changes,
285
+ * repository status changes, etc.
286
+ *
287
+ * @defaultValue true
288
+ */
289
+ refresh?: boolean;
290
+ /**
291
+ * Unregister entities that are deleted as a result of an SCM event.
292
+ *
293
+ * @defaultValue true
294
+ */
295
+ unregister?: boolean;
296
+ /**
297
+ * Move entities that are moved as a result of an SCM event.
298
+ *
299
+ * @defaultValue true
300
+ */
301
+ move?: boolean;
302
+ };
260
303
  };
261
304
  }
@@ -16,6 +16,7 @@ const MAX_KEY_LENGTH = 200;
16
16
  const MAX_VALUE_LENGTH = 200;
17
17
  function traverse(root) {
18
18
  const output = [];
19
+ const seenPathKeys = /* @__PURE__ */ new Set();
19
20
  function visit(path, current) {
20
21
  if (SPECIAL_KEYS.includes(path)) {
21
22
  return;
@@ -32,9 +33,9 @@ function traverse(root) {
32
33
  visit(path, item);
33
34
  if (typeof item === "string") {
34
35
  const pathKey = `${path}.${item}`;
35
- if (!output.some(
36
- (kv) => kv.key.toLocaleLowerCase("en-US") === pathKey.toLocaleLowerCase("en-US")
37
- )) {
36
+ const lowerKey = pathKey.toLocaleLowerCase("en-US");
37
+ if (!seenPathKeys.has(lowerKey)) {
38
+ seenPathKeys.add(lowerKey);
38
39
  output.push({ key: pathKey, value: true });
39
40
  }
40
41
  }
@@ -1 +1 @@
1
- {"version":3,"file":"buildEntitySearch.cjs.js","sources":["../../../../src/database/operations/stitcher/buildEntitySearch.ts"],"sourcesContent":["/*\n * Copyright 2020 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 { DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model';\nimport { InputError } from '@backstage/errors';\nimport { DbSearchRow } from '../../tables';\n\n// These are excluded in the generic loop, either because they do not make sense\n// to index, or because they are special-case always inserted whether they are\n// null or not\nconst SPECIAL_KEYS = [\n 'attachments',\n 'relations',\n 'status',\n 'metadata.name',\n 'metadata.namespace',\n 'metadata.uid',\n 'metadata.etag',\n];\n\n// The maximum length allowed for search values. These columns are indexed, and\n// database engines do not like to index on massive values. For example,\n// postgres will balk after 8191 byte line sizes.\nconst MAX_KEY_LENGTH = 200;\nconst MAX_VALUE_LENGTH = 200;\n\ntype Kv = {\n key: string;\n value: unknown;\n};\n\n// Helper for traversing through a nested structure and outputting a list of\n// path->value entries of the leaves.\n//\n// For example, this yaml structure\n//\n// a: 1\n// b:\n// c: null\n// e: [f, g]\n// h:\n// - i: 1\n// j: k\n// - i: 2\n// j: l\n//\n// will result in\n//\n// \"a\", 1\n// \"b.c\", null\n// \"b.e\": \"f\"\n// \"b.e.f\": true\n// \"b.e\": \"g\"\n// \"b.e.g\": true\n// \"h.i\": 1\n// \"h.j\": \"k\"\n// \"h.i\": 2\n// \"h.j\": \"l\"\nexport function traverse(root: unknown): Kv[] {\n const output: Kv[] = [];\n\n function visit(path: string, current: unknown) {\n if (SPECIAL_KEYS.includes(path)) {\n return;\n }\n\n // empty or scalar\n if (\n current === undefined ||\n current === null ||\n ['string', 'number', 'boolean'].includes(typeof current)\n ) {\n output.push({ key: path, value: current });\n return;\n }\n\n // unknown\n if (typeof current !== 'object') {\n return;\n }\n\n // array\n if (Array.isArray(current)) {\n for (const item of current) {\n // NOTE(freben): The reason that these are output in two different ways,\n // is to support use cases where you want to express that MORE than one\n // tag is present in a list. Since the EntityFilters structure is a\n // record, you can't have several entries of the same key. Therefore\n // you will have to match on\n //\n // { \"a.b\": [\"true\"], \"a.c\": [\"true\"] }\n //\n // rather than\n //\n // { \"a\": [\"b\", \"c\"] }\n //\n // because the latter means EITHER b or c has to be present.\n visit(path, item);\n if (typeof item === 'string') {\n const pathKey = `${path}.${item}`;\n if (\n !output.some(\n kv =>\n kv.key.toLocaleLowerCase('en-US') ===\n pathKey.toLocaleLowerCase('en-US'),\n )\n ) {\n output.push({ key: pathKey, value: true });\n }\n }\n }\n return;\n }\n\n // object\n for (const [key, value] of Object.entries(current!)) {\n visit(path ? `${path}.${key}` : key, value);\n }\n }\n\n visit('', root);\n\n return output;\n}\n\n// Translates a number of raw data rows to search table rows\nexport function mapToRows(input: Kv[], entityId: string): DbSearchRow[] {\n const result: DbSearchRow[] = [];\n\n for (const { key: rawKey, value: rawValue } of input) {\n const key = rawKey.toLocaleLowerCase('en-US');\n if (key.length > MAX_KEY_LENGTH) {\n continue;\n }\n if (rawValue === undefined || rawValue === null) {\n result.push({\n entity_id: entityId,\n key,\n original_value: null,\n value: null,\n });\n } else {\n const value = String(rawValue).toLocaleLowerCase('en-US');\n if (value.length <= MAX_VALUE_LENGTH) {\n result.push({\n entity_id: entityId,\n key,\n original_value: String(rawValue),\n value: value,\n });\n } else {\n result.push({\n entity_id: entityId,\n key,\n original_value: null,\n value: null,\n });\n }\n }\n }\n\n return result;\n}\n\n/**\n * Generates all of the search rows that are relevant for this entity.\n *\n * @param entityId - The uid of the entity\n * @param entity - The entity\n * @returns A list of entity search rows\n */\nexport function buildEntitySearch(\n entityId: string,\n entity: Entity,\n): DbSearchRow[] {\n // Visit the base structure recursively\n const raw = traverse(entity);\n\n // Start with some special keys that are always present because you want to\n // be able to easily search for null specifically\n raw.push({ key: 'metadata.name', value: entity.metadata.name });\n raw.push({ key: 'metadata.namespace', value: entity.metadata.namespace });\n raw.push({ key: 'metadata.uid', value: entity.metadata.uid });\n\n // Namespace not specified has the default value \"default\", so we want to\n // match on that as well\n if (!entity.metadata.namespace) {\n raw.push({ key: 'metadata.namespace', value: DEFAULT_NAMESPACE });\n }\n\n // Visit relations\n for (const relation of entity.relations ?? []) {\n raw.push({\n key: `relations.${relation.type}`,\n value: relation.targetRef,\n });\n }\n\n // This validates that there are no keys that vary only in casing, such\n // as `spec.foo` and `spec.Foo`.\n const keys = new Set(raw.map(r => r.key));\n const lowerKeys = new Set(raw.map(r => r.key.toLocaleLowerCase('en-US')));\n if (keys.size !== lowerKeys.size) {\n const difference = [];\n for (const key of keys) {\n const lower = key.toLocaleLowerCase('en-US');\n if (!lowerKeys.delete(lower)) {\n difference.push(lower);\n }\n }\n const badKeys = `'${difference.join(\"', '\")}'`;\n throw new InputError(\n `Entity has duplicate keys that vary only in casing, ${badKeys}`,\n );\n }\n\n return mapToRows(raw, entityId);\n}\n"],"names":["DEFAULT_NAMESPACE","InputError"],"mappings":"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"}
1
+ {"version":3,"file":"buildEntitySearch.cjs.js","sources":["../../../../src/database/operations/stitcher/buildEntitySearch.ts"],"sourcesContent":["/*\n * Copyright 2020 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 { DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model';\nimport { InputError } from '@backstage/errors';\nimport { DbSearchRow } from '../../tables';\n\n// These are excluded in the generic loop, either because they do not make sense\n// to index, or because they are special-case always inserted whether they are\n// null or not\nconst SPECIAL_KEYS = [\n 'attachments',\n 'relations',\n 'status',\n 'metadata.name',\n 'metadata.namespace',\n 'metadata.uid',\n 'metadata.etag',\n];\n\n// The maximum length allowed for search values. These columns are indexed, and\n// database engines do not like to index on massive values. For example,\n// postgres will balk after 8191 byte line sizes.\nconst MAX_KEY_LENGTH = 200;\nconst MAX_VALUE_LENGTH = 200;\n\ntype Kv = {\n key: string;\n value: unknown;\n};\n\n// Helper for traversing through a nested structure and outputting a list of\n// path->value entries of the leaves.\n//\n// For example, this yaml structure\n//\n// a: 1\n// b:\n// c: null\n// e: [f, g]\n// h:\n// - i: 1\n// j: k\n// - i: 2\n// j: l\n//\n// will result in\n//\n// \"a\", 1\n// \"b.c\", null\n// \"b.e\": \"f\"\n// \"b.e.f\": true\n// \"b.e\": \"g\"\n// \"b.e.g\": true\n// \"h.i\": 1\n// \"h.j\": \"k\"\n// \"h.i\": 2\n// \"h.j\": \"l\"\nexport function traverse(root: unknown): Kv[] {\n const output: Kv[] = [];\n // Use a Set for O(1) case-insensitive duplicate detection of synthetic\n // boolean path keys (e.g. \"metadata.tags.java\"), instead of the previous\n // O(n) Array.some() linear scan which caused O(n²) overall complexity\n // and severe event loop blocking for entities with large arrays.\n const seenPathKeys = new Set<string>();\n\n function visit(path: string, current: unknown) {\n if (SPECIAL_KEYS.includes(path)) {\n return;\n }\n\n // empty or scalar\n if (\n current === undefined ||\n current === null ||\n ['string', 'number', 'boolean'].includes(typeof current)\n ) {\n output.push({ key: path, value: current });\n return;\n }\n\n // unknown\n if (typeof current !== 'object') {\n return;\n }\n\n // array\n if (Array.isArray(current)) {\n for (const item of current) {\n // NOTE(freben): The reason that these are output in two different ways,\n // is to support use cases where you want to express that MORE than one\n // tag is present in a list. Since the EntityFilters structure is a\n // record, you can't have several entries of the same key. Therefore\n // you will have to match on\n //\n // { \"a.b\": [\"true\"], \"a.c\": [\"true\"] }\n //\n // rather than\n //\n // { \"a\": [\"b\", \"c\"] }\n //\n // because the latter means EITHER b or c has to be present.\n visit(path, item);\n if (typeof item === 'string') {\n const pathKey = `${path}.${item}`;\n const lowerKey = pathKey.toLocaleLowerCase('en-US');\n if (!seenPathKeys.has(lowerKey)) {\n seenPathKeys.add(lowerKey);\n output.push({ key: pathKey, value: true });\n }\n }\n }\n return;\n }\n\n // object\n for (const [key, value] of Object.entries(current!)) {\n visit(path ? `${path}.${key}` : key, value);\n }\n }\n\n visit('', root);\n\n return output;\n}\n\n// Translates a number of raw data rows to search table rows\nexport function mapToRows(input: Kv[], entityId: string): DbSearchRow[] {\n const result: DbSearchRow[] = [];\n\n for (const { key: rawKey, value: rawValue } of input) {\n const key = rawKey.toLocaleLowerCase('en-US');\n if (key.length > MAX_KEY_LENGTH) {\n continue;\n }\n if (rawValue === undefined || rawValue === null) {\n result.push({\n entity_id: entityId,\n key,\n original_value: null,\n value: null,\n });\n } else {\n const value = String(rawValue).toLocaleLowerCase('en-US');\n if (value.length <= MAX_VALUE_LENGTH) {\n result.push({\n entity_id: entityId,\n key,\n original_value: String(rawValue),\n value: value,\n });\n } else {\n result.push({\n entity_id: entityId,\n key,\n original_value: null,\n value: null,\n });\n }\n }\n }\n\n return result;\n}\n\n/**\n * Generates all of the search rows that are relevant for this entity.\n *\n * @param entityId - The uid of the entity\n * @param entity - The entity\n * @returns A list of entity search rows\n */\nexport function buildEntitySearch(\n entityId: string,\n entity: Entity,\n): DbSearchRow[] {\n // Visit the base structure recursively\n const raw = traverse(entity);\n\n // Start with some special keys that are always present because you want to\n // be able to easily search for null specifically\n raw.push({ key: 'metadata.name', value: entity.metadata.name });\n raw.push({ key: 'metadata.namespace', value: entity.metadata.namespace });\n raw.push({ key: 'metadata.uid', value: entity.metadata.uid });\n\n // Namespace not specified has the default value \"default\", so we want to\n // match on that as well\n if (!entity.metadata.namespace) {\n raw.push({ key: 'metadata.namespace', value: DEFAULT_NAMESPACE });\n }\n\n // Visit relations\n for (const relation of entity.relations ?? []) {\n raw.push({\n key: `relations.${relation.type}`,\n value: relation.targetRef,\n });\n }\n\n // This validates that there are no keys that vary only in casing, such\n // as `spec.foo` and `spec.Foo`.\n const keys = new Set(raw.map(r => r.key));\n const lowerKeys = new Set(raw.map(r => r.key.toLocaleLowerCase('en-US')));\n if (keys.size !== lowerKeys.size) {\n const difference = [];\n for (const key of keys) {\n const lower = key.toLocaleLowerCase('en-US');\n if (!lowerKeys.delete(lower)) {\n difference.push(lower);\n }\n }\n const badKeys = `'${difference.join(\"', '\")}'`;\n throw new InputError(\n `Entity has duplicate keys that vary only in casing, ${badKeys}`,\n );\n }\n\n return mapToRows(raw, entityId);\n}\n"],"names":["DEFAULT_NAMESPACE","InputError"],"mappings":"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"}
@@ -5,12 +5,22 @@ var uuid = require('uuid');
5
5
  var util = require('../processing/util.cjs.js');
6
6
  var conversion = require('../util/conversion.cjs.js');
7
7
  var catalogModel = require('@backstage/catalog-model');
8
+ var lodash = require('lodash');
9
+ var parseGitUrl = require('git-url-parse');
10
+
11
+ function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
12
+
13
+ var parseGitUrl__default = /*#__PURE__*/_interopDefaultCompat(parseGitUrl);
8
14
 
9
15
  class DefaultLocationStore {
10
16
  _connection;
11
17
  db;
12
- constructor(db) {
18
+ scmEvents;
19
+ scmEventHandlingConfig;
20
+ constructor(db, scmEvents, scmEventHandlingConfig) {
13
21
  this.db = db;
22
+ this.scmEvents = scmEvents;
23
+ this.scmEventHandlingConfig = scmEventHandlingConfig;
14
24
  }
15
25
  getProviderName() {
16
26
  return "DefaultLocationStore";
@@ -45,6 +55,36 @@ class DefaultLocationStore {
45
55
  async listLocations() {
46
56
  return await this.locations();
47
57
  }
58
+ async queryLocations(options) {
59
+ let itemsQuery = this.db("locations").whereNot(
60
+ "type",
61
+ "bootstrap"
62
+ );
63
+ if (options.query) {
64
+ itemsQuery = applyLocationFilterToQuery(
65
+ this.db.client.config.client,
66
+ itemsQuery,
67
+ options.query
68
+ );
69
+ }
70
+ const countQuery = itemsQuery.clone().count("*", { as: "count" });
71
+ itemsQuery = itemsQuery.orderBy("id", "asc");
72
+ if (options.afterId !== void 0) {
73
+ itemsQuery = itemsQuery.where("id", ">", options.afterId);
74
+ }
75
+ if (options.limit !== void 0) {
76
+ itemsQuery = itemsQuery.limit(options.limit);
77
+ }
78
+ const [items, [{ count }]] = await Promise.all([itemsQuery, countQuery]);
79
+ return {
80
+ items: items.map((item) => ({
81
+ id: item.id,
82
+ target: item.target,
83
+ type: item.type
84
+ })),
85
+ totalItems: Number(count)
86
+ };
87
+ }
48
88
  async getLocation(id) {
49
89
  const items = await this.db("locations").where({ id }).select();
50
90
  if (!items.length) {
@@ -112,6 +152,9 @@ class DefaultLocationStore {
112
152
  type: "full",
113
153
  entities
114
154
  });
155
+ if (this.scmEventHandlingConfig.unregister || this.scmEventHandlingConfig.move) {
156
+ this.scmEvents.subscribe({ onEvents: this.#onScmEvents.bind(this) });
157
+ }
115
158
  }
116
159
  async locations(dbOrTx = this.db) {
117
160
  const locations = await dbOrTx("locations").select();
@@ -121,6 +164,302 @@ class DefaultLocationStore {
121
164
  type: item.type
122
165
  }));
123
166
  }
167
+ // #region SCM event handling
168
+ async #onScmEvents(events) {
169
+ const exactLocationsToDelete = /* @__PURE__ */ new Set();
170
+ const locationPrefixesToDelete = /* @__PURE__ */ new Set();
171
+ const exactLocationsToCreate = /* @__PURE__ */ new Set();
172
+ const locationPrefixesToMove = /* @__PURE__ */ new Map();
173
+ for (const event of events) {
174
+ if (event.type === "location.deleted" && this.scmEventHandlingConfig.unregister) {
175
+ exactLocationsToDelete.add(event.url);
176
+ } else if (event.type === "location.moved" && this.scmEventHandlingConfig.move) {
177
+ exactLocationsToDelete.add(event.fromUrl);
178
+ exactLocationsToCreate.add(event.toUrl);
179
+ } else if (event.type === "repository.deleted" && this.scmEventHandlingConfig.unregister) {
180
+ locationPrefixesToDelete.add(event.url);
181
+ } else if (event.type === "repository.moved" && this.scmEventHandlingConfig.move) {
182
+ locationPrefixesToMove.set(event.fromUrl, event.toUrl);
183
+ }
184
+ }
185
+ if (exactLocationsToDelete.size > 0) {
186
+ await this.#deleteLocationsByExactUrl(exactLocationsToDelete);
187
+ }
188
+ if (locationPrefixesToDelete.size > 0) {
189
+ await this.#deleteLocationsByUrlPrefix(locationPrefixesToDelete);
190
+ }
191
+ if (exactLocationsToCreate.size > 0) {
192
+ await this.#createLocationsByExactUrl(exactLocationsToCreate);
193
+ }
194
+ if (locationPrefixesToMove.size > 0) {
195
+ await this.#moveLocationsByUrlPrefix(locationPrefixesToMove);
196
+ }
197
+ }
198
+ async #createLocationsByExactUrl(urls) {
199
+ let count = 0;
200
+ for (const batch of lodash.chunk(Array.from(urls), 100)) {
201
+ const existingUrls = await this.db("locations").where("type", "=", "url").whereIn("target", batch).select().then((rows) => new Set(rows.map((row) => row.target)));
202
+ const newLocations = batch.filter((url) => !existingUrls.has(url)).map((url) => ({ id: uuid.v4(), type: "url", target: url }));
203
+ if (newLocations.length) {
204
+ await this.db("locations").insert(newLocations);
205
+ await this.connection.applyMutation({
206
+ type: "delta",
207
+ added: newLocations.map((location) => {
208
+ const entity = conversion.locationSpecToLocationEntity({ location });
209
+ return { entity, locationKey: util.getEntityLocationRef(entity) };
210
+ }),
211
+ removed: []
212
+ });
213
+ count += newLocations.length;
214
+ }
215
+ }
216
+ return count;
217
+ }
218
+ async #deleteLocationsByExactUrl(urls) {
219
+ let count = 0;
220
+ for (const batch of lodash.chunk(Array.from(urls), 100)) {
221
+ const rows = await this.db("locations").where("type", "=", "url").whereIn("target", batch).select();
222
+ if (rows.length) {
223
+ await this.db("locations").whereIn(
224
+ "id",
225
+ rows.map((row) => row.id)
226
+ ).delete();
227
+ await this.connection.applyMutation({
228
+ type: "delta",
229
+ added: [],
230
+ removed: rows.map((row) => ({
231
+ entity: conversion.locationSpecToLocationEntity({ location: row })
232
+ }))
233
+ });
234
+ count += rows.length;
235
+ }
236
+ }
237
+ return count;
238
+ }
239
+ async #deleteLocationsByUrlPrefix(urls) {
240
+ const matches = await this.#findLocationsByPrefixOrExactMatch(urls);
241
+ if (matches.length) {
242
+ await this.#deleteLocations(matches.map((l) => l.row));
243
+ }
244
+ return matches.length;
245
+ }
246
+ async #moveLocationsByUrlPrefix(urlPrefixes) {
247
+ let count = 0;
248
+ for (const [fromPrefix, toPrefix] of urlPrefixes) {
249
+ if (fromPrefix === toPrefix) {
250
+ continue;
251
+ }
252
+ if (fromPrefix.match(/[?#]/) || toPrefix.match(/[?#]/)) {
253
+ continue;
254
+ }
255
+ const matches = await this.#findLocationsByPrefixOrExactMatch([
256
+ fromPrefix
257
+ ]);
258
+ if (matches.length) {
259
+ await this.#deleteLocations(matches.map((m) => m.row));
260
+ await this.#createLocationsByExactUrl(
261
+ matches.map((m) => {
262
+ const remainder = m.row.target.slice(fromPrefix.length).replace(/^\/+/, "");
263
+ if (!remainder) {
264
+ return toPrefix;
265
+ }
266
+ return `${toPrefix.replace(/\/+$/, "")}/${remainder}`;
267
+ })
268
+ );
269
+ count += matches.length;
270
+ }
271
+ }
272
+ return count;
273
+ }
274
+ async #deleteLocations(rows) {
275
+ for (const ids of lodash.chunk(
276
+ rows.map((l) => l.id),
277
+ 100
278
+ )) {
279
+ await this.db("locations").whereIn("id", ids).delete();
280
+ }
281
+ await this.connection.applyMutation({
282
+ type: "delta",
283
+ added: [],
284
+ removed: rows.map((l) => ({
285
+ entity: conversion.locationSpecToLocationEntity({ location: l })
286
+ }))
287
+ });
288
+ }
289
+ /**
290
+ * Given a "base" URL prefix, find all locations that are for paths at or
291
+ * below it.
292
+ *
293
+ * For example, given a base URL prefix of
294
+ * "https://github.com/backstage/backstage/blob/master/plugins", it will match
295
+ * locations inside the plugins directory, and nowhere else.
296
+ */
297
+ async #findLocationsByPrefixOrExactMatch(urls) {
298
+ const result = new Array();
299
+ for (const url of urls) {
300
+ let base;
301
+ try {
302
+ base = parseGitUrl__default.default(url);
303
+ } catch (error) {
304
+ throw new Error(`Invalid URL prefix, could not parse: ${url}`);
305
+ }
306
+ if (!base.owner || !base.name) {
307
+ throw new Error(
308
+ `Invalid URL prefix, missing owner or repository: ${url}`
309
+ );
310
+ }
311
+ const pathPrefix = base.filepath === "" || base.filepath.endsWith("/") ? base.filepath : `${base.filepath}/`;
312
+ const rows = await this.db("locations").where("type", "=", "url").where("target", "like", `%${base.owner}%`).where("target", "like", `%${base.name}%`).select();
313
+ result.push(
314
+ ...rows.flatMap((row) => {
315
+ try {
316
+ const candidate = parseGitUrl__default.default(row.target);
317
+ if (candidate.protocol === base.protocol && candidate.resource === base.resource && candidate.port === base.port && candidate.organization === base.organization && candidate.owner === base.owner && candidate.name === base.name && // If the base has no ref (for example didn't have the "/blob/master"
318
+ // part and therefore targeted an entire repository) then we match any
319
+ // ref below that
320
+ (!base.ref || candidate.ref === base.ref) && // Match both on exact equality and any subpath with a slash between
321
+ (candidate.filepath === base.filepath || candidate.filepath.startsWith(pathPrefix))) {
322
+ return [{ row, parsed: candidate }];
323
+ }
324
+ return [];
325
+ } catch {
326
+ return [];
327
+ }
328
+ })
329
+ );
330
+ }
331
+ return lodash.uniqBy(result, (entry) => entry.row.id);
332
+ }
333
+ // #endregion
334
+ }
335
+ function applyLocationFilterToQuery(clientType, inputQuery, query) {
336
+ let result = inputQuery;
337
+ if (!query || typeof query !== "object" || Array.isArray(query)) {
338
+ throw new errors.InputError("Invalid filter predicate, expected an object");
339
+ }
340
+ if ("$all" in query) {
341
+ if (query.$all.length === 0) {
342
+ return result.whereRaw("1 = 0");
343
+ }
344
+ return result.where((outer) => {
345
+ for (const subQuery of query.$all) {
346
+ outer.andWhere((inner) => {
347
+ applyLocationFilterToQuery(clientType, inner, subQuery);
348
+ });
349
+ }
350
+ });
351
+ }
352
+ if ("$any" in query) {
353
+ if (query.$any.length === 0) {
354
+ return result.whereRaw("1 = 0");
355
+ }
356
+ return result.where((outer) => {
357
+ for (const subQuery of query.$any) {
358
+ outer.orWhere((inner) => {
359
+ applyLocationFilterToQuery(clientType, inner, subQuery);
360
+ });
361
+ }
362
+ });
363
+ }
364
+ if ("$not" in query) {
365
+ return result.whereNot((inner) => {
366
+ applyLocationFilterToQuery(clientType, inner, query.$not);
367
+ });
368
+ }
369
+ const entries = Object.entries(query);
370
+ const keys = entries.map((e) => e[0]);
371
+ if (keys.some((k) => k.startsWith("$"))) {
372
+ throw new errors.InputError(
373
+ `Invalid filter predicate, unknown logic operator '${keys.join(", ")}'`
374
+ );
375
+ }
376
+ for (const [keyAnyCase, value] of entries) {
377
+ const key = keyAnyCase.toLocaleLowerCase("en-US");
378
+ if (!["id", "type", "target"].includes(key)) {
379
+ throw new errors.InputError(
380
+ `Invalid filter predicate, expected key to be 'id', 'type', or 'target', got '${keyAnyCase}'`
381
+ );
382
+ }
383
+ result = applyFilterValueToQuery(clientType, result, key, value);
384
+ }
385
+ return result;
386
+ }
387
+ function applyFilterValueToQuery(clientType, result, key, value) {
388
+ if (["string", "number", "boolean"].includes(typeof value)) {
389
+ if (clientType === "pg") {
390
+ return result.whereRaw(`UPPER(??::text) = UPPER(?::text)`, [key, value]);
391
+ }
392
+ if (clientType.includes("mysql")) {
393
+ return result.whereRaw(
394
+ `UPPER(CAST(?? AS CHAR)) = UPPER(CAST(? AS CHAR))`,
395
+ [key, value]
396
+ );
397
+ }
398
+ return result.whereRaw(`UPPER(??) = UPPER(?)`, [key, value]);
399
+ }
400
+ if (typeof value === "object") {
401
+ if (!value || Array.isArray(value)) {
402
+ throw new errors.InputError(
403
+ `Invalid filter predicate, got unknown matcher object '${JSON.stringify(
404
+ value
405
+ )}'`
406
+ );
407
+ }
408
+ if ("$exists" in value) {
409
+ return value.$exists ? result.whereNotNull(key) : result.whereNull(key);
410
+ }
411
+ if ("$in" in value) {
412
+ if (value.$in.length === 0) {
413
+ return result.whereRaw("1 = 0");
414
+ }
415
+ if (key === "id") {
416
+ return result.whereIn(key, value.$in);
417
+ }
418
+ if (clientType === "pg") {
419
+ const rhs2 = value.$in.map(() => "UPPER(?::text)").join(", ");
420
+ return result.whereRaw(`UPPER(??::text) IN (${rhs2})`, [
421
+ key,
422
+ ...value.$in
423
+ ]);
424
+ }
425
+ if (clientType.includes("mysql")) {
426
+ const rhs2 = value.$in.map(() => "UPPER(CAST(? AS CHAR))").join(", ");
427
+ return result.whereRaw(`UPPER(CAST(?? AS CHAR)) IN (${rhs2})`, [
428
+ key,
429
+ ...value.$in
430
+ ]);
431
+ }
432
+ const rhs = value.$in.map(() => "UPPER(?)").join(", ");
433
+ return result.whereRaw(`UPPER(??) IN (${rhs})`, [key, ...value.$in]);
434
+ }
435
+ if ("$hasPrefix" in value) {
436
+ const escaped = value.$hasPrefix.replace(/([\\%_])/g, "\\$1");
437
+ if (clientType === "pg") {
438
+ return result.whereRaw("?? ilike ? escape '\\'", [key, `${escaped}%`]);
439
+ }
440
+ if (clientType.includes("mysql")) {
441
+ return result.whereRaw("UPPER(??) like UPPER(?) escape '\\\\'", [
442
+ key,
443
+ `${escaped}%`
444
+ ]);
445
+ }
446
+ return result.whereRaw("UPPER(??) like UPPER(?) escape '\\'", [
447
+ key,
448
+ `${escaped}%`
449
+ ]);
450
+ }
451
+ if ("$contains" in value) {
452
+ return result.whereRaw("1 = 0");
453
+ }
454
+ throw new errors.InputError(
455
+ `Invalid filter predicate, got unknown matcher object '${JSON.stringify(
456
+ value
457
+ )}'`
458
+ );
459
+ }
460
+ throw new errors.InputError(
461
+ `Invalid filter predicate, expected value to be a primitive value or a matcher object, got '${typeof value}'`
462
+ );
124
463
  }
125
464
 
126
465
  exports.DefaultLocationStore = DefaultLocationStore;