@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 +68 -0
- package/config.d.ts +43 -0
- package/dist/database/operations/stitcher/buildEntitySearch.cjs.js +4 -3
- package/dist/database/operations/stitcher/buildEntitySearch.cjs.js.map +1 -1
- package/dist/providers/DefaultLocationStore.cjs.js +340 -1
- package/dist/providers/DefaultLocationStore.cjs.js.map +1 -1
- package/dist/providers/GenericScmEventRefreshProvider.cjs.js +83 -0
- package/dist/providers/GenericScmEventRefreshProvider.cjs.js.map +1 -0
- package/dist/schema/openapi/generated/router.cjs.js +76 -1
- package/dist/schema/openapi/generated/router.cjs.js.map +1 -1
- package/dist/service/AuthorizedLocationService.cjs.js +10 -0
- package/dist/service/AuthorizedLocationService.cjs.js.map +1 -1
- package/dist/service/CatalogBuilder.cjs.js +17 -3
- package/dist/service/CatalogBuilder.cjs.js.map +1 -1
- package/dist/service/CatalogPlugin.cjs.js +6 -3
- package/dist/service/CatalogPlugin.cjs.js.map +1 -1
- package/dist/service/DefaultLocationService.cjs.js +7 -0
- package/dist/service/DefaultLocationService.cjs.js.map +1 -1
- package/dist/service/createRouter.cjs.js +35 -0
- package/dist/service/createRouter.cjs.js.map +1 -1
- package/dist/service/request/parseLocationQuery.cjs.js +75 -0
- package/dist/service/request/parseLocationQuery.cjs.js.map +1 -0
- package/dist/util/readScmEventHandlingConfig.cjs.js +28 -0
- package/dist/util/readScmEventHandlingConfig.cjs.js.map +1 -0
- package/package.json +22 -20
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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;
|