@dudousxd/nestjs-catalog 0.6.0 → 0.8.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/dist/catalog.controller.d.ts +6 -0
- package/dist/catalog.controller.js +54 -0
- package/dist/catalog.decorators.d.ts +17 -0
- package/dist/catalog.decorators.js +17 -0
- package/dist/catalog.environment.d.ts +39 -0
- package/dist/catalog.environment.js +50 -0
- package/dist/catalog.pipeline.d.ts +19 -0
- package/dist/catalog.registry.base.d.ts +46 -1
- package/dist/catalog.registry.base.js +102 -0
- package/dist/catalog.registry.d.ts +1 -2
- package/dist/catalog.registry.js +53 -39
- package/dist/catalog.service.d.ts +17 -0
- package/dist/catalog.service.js +72 -0
- package/dist/catalog.types.d.ts +107 -2
- package/dist/client.d.ts +11 -0
- package/dist/client.js +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -2
- package/dist/search.d.ts +184 -0
- package/dist/search.js +345 -0
- package/dist/search.types.d.ts +93 -0
- package/dist/search.types.js +17 -0
- package/package.json +1 -1
|
@@ -42,6 +42,12 @@ import { type Type } from '@nestjs/common';
|
|
|
42
42
|
* every label one request at a time. Requiring admin would deny nothing and
|
|
43
43
|
* would push a routine console action into the scope that manages principals.
|
|
44
44
|
*
|
|
45
|
+
* **`GET search` is the one read here that looks at the principal.** Its scope
|
|
46
|
+
* is the unsurprising `catalog:read`; what is surprising is that it filters, in
|
|
47
|
+
* a library whose read routes deliberately do not. The reason it has to is on
|
|
48
|
+
* the route itself, and it is short: a host can wrap a read whose subject it
|
|
49
|
+
* knows, and cannot wrap one whose result set is chosen by a stranger's typing.
|
|
50
|
+
*
|
|
45
51
|
* One consequence is worth stating rather than leaving to be discovered:
|
|
46
52
|
* `shared` rides on the dashboard write routes, so `catalog:curate` carries the
|
|
47
53
|
* power to hand a board to an outside application. That is not an oversight —
|
|
@@ -62,6 +62,12 @@ const catalog_workspace_1 = require("./catalog.workspace");
|
|
|
62
62
|
* every label one request at a time. Requiring admin would deny nothing and
|
|
63
63
|
* would push a routine console action into the scope that manages principals.
|
|
64
64
|
*
|
|
65
|
+
* **`GET search` is the one read here that looks at the principal.** Its scope
|
|
66
|
+
* is the unsurprising `catalog:read`; what is surprising is that it filters, in
|
|
67
|
+
* a library whose read routes deliberately do not. The reason it has to is on
|
|
68
|
+
* the route itself, and it is short: a host can wrap a read whose subject it
|
|
69
|
+
* knows, and cannot wrap one whose result set is chosen by a stranger's typing.
|
|
70
|
+
*
|
|
65
71
|
* One consequence is worth stating rather than leaving to be discovered:
|
|
66
72
|
* `shared` rides on the dashboard write routes, so `catalog:curate` carries the
|
|
67
73
|
* power to hand a board to an outside application. That is not an oversight —
|
|
@@ -95,6 +101,44 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
95
101
|
graph() {
|
|
96
102
|
return this.registry.getGraph();
|
|
97
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* One term, across object types, properties, saved queries and dashboards.
|
|
106
|
+
*
|
|
107
|
+
* `catalog:read`, which is the scope of the four routes this is a shortcut
|
|
108
|
+
* to — `GET /catalog`, `GET saved-queries`, `GET dashboards` — and
|
|
109
|
+
* deliberately not narrower. A discovery route gated harder than the things
|
|
110
|
+
* it discovers is a route nobody can use; one gated softer is a way around
|
|
111
|
+
* them. Nothing here reaches a row of data or a statement: it returns names,
|
|
112
|
+
* labels and ids, which is a strict subset of what `GET /catalog` already
|
|
113
|
+
* hands the same caller.
|
|
114
|
+
*
|
|
115
|
+
* **The principal is passed, and this is one of two routes on this
|
|
116
|
+
* controller that filter by it.** The library's usual position is that reads
|
|
117
|
+
* apply no grants and a host wraps them — stated at length above `mayWrite`
|
|
118
|
+
* in `catalog.principal.ts`, and true of `GET objects/:name` right below.
|
|
119
|
+
* That position does not survive contact with a search box. A host can wrap
|
|
120
|
+
* `readObjects` because it knows the type being read and can decide; it
|
|
121
|
+
* cannot wrap this, because the whole result set is chosen by what somebody
|
|
122
|
+
* typed and the thing that must not come back is a NAME, which no wrapper
|
|
123
|
+
* downstream can distinguish from a name that was fine. Filtering has to
|
|
124
|
+
* happen where the candidates are enumerated, so it happens in
|
|
125
|
+
* `CatalogService.search`, and `visibleToPrincipal` is where to read the
|
|
126
|
+
* rules.
|
|
127
|
+
*
|
|
128
|
+
* The `@Req()` here is the same shape `actorOf` reads below, and for the
|
|
129
|
+
* same reason: a guard the host wrote put it there, and when no guard did,
|
|
130
|
+
* `undefined` arrives and nothing is filtered — see the service.
|
|
131
|
+
*
|
|
132
|
+
* Declared before every route carrying a parameter in the first segment,
|
|
133
|
+
* which today is none of them, so this is defensive rather than load-bearing.
|
|
134
|
+
* `events/traces` further down is the case where it is not.
|
|
135
|
+
*/
|
|
136
|
+
search(q, limit, request) {
|
|
137
|
+
return this.service.search(q ?? '', {
|
|
138
|
+
principal: request?.principal,
|
|
139
|
+
limit: limit ? Number(limit) : undefined,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
98
142
|
type(name) {
|
|
99
143
|
const type = this.registry.getType(name);
|
|
100
144
|
if (!type)
|
|
@@ -347,6 +391,16 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
347
391
|
__metadata("design:paramtypes", []),
|
|
348
392
|
__metadata("design:returntype", void 0)
|
|
349
393
|
], CatalogController.prototype, "graph", null);
|
|
394
|
+
__decorate([
|
|
395
|
+
(0, common_1.Get)('search'),
|
|
396
|
+
(0, catalog_route_auth_1.RequireScopes)('catalog:read'),
|
|
397
|
+
__param(0, (0, common_1.Query)('q')),
|
|
398
|
+
__param(1, (0, common_1.Query)('limit')),
|
|
399
|
+
__param(2, (0, common_1.Req)()),
|
|
400
|
+
__metadata("design:type", Function),
|
|
401
|
+
__metadata("design:paramtypes", [String, String, Object]),
|
|
402
|
+
__metadata("design:returntype", void 0)
|
|
403
|
+
], CatalogController.prototype, "search", null);
|
|
350
404
|
__decorate([
|
|
351
405
|
(0, common_1.Get)('types/:name'),
|
|
352
406
|
(0, catalog_route_auth_1.RequireScopes)('catalog:read'),
|
|
@@ -31,6 +31,23 @@ export declare function CatalogType(options?: CatalogTypeOptions): ClassDecorato
|
|
|
31
31
|
* Enriches one property. Everything it sets is tier 0 — the overlay can
|
|
32
32
|
* override any of it at runtime without a migration, which is precisely why
|
|
33
33
|
* these live in metadata rather than in the column definition.
|
|
34
|
+
*
|
|
35
|
+
* **This is also how a relation is enriched, and there is deliberately no
|
|
36
|
+
* `@CatalogRelation`.** The metadata is keyed by property name, and a
|
|
37
|
+
* `@ManyToOne` is a property; the registry looks the options up before it
|
|
38
|
+
* decides whether the field is a scalar or a link, so
|
|
39
|
+
* `@CatalogProperty({ displayName: 'Home base' })` on `Mvr.base` labels the link
|
|
40
|
+
* exactly as it labels a column. A second decorator would be a synonym for this
|
|
41
|
+
* one.
|
|
42
|
+
*
|
|
43
|
+
* A decorator that declared a relation *outright* — target, kind, join column —
|
|
44
|
+
* was considered and rejected twice over. Everything an ORM models is already
|
|
45
|
+
* derived, and a hand-written line that could restate it is a line that can
|
|
46
|
+
* disagree with the schema, which is the one thing this model does not allow.
|
|
47
|
+
* And the links an ORM genuinely cannot see are, in practice, the ones that
|
|
48
|
+
* cross applications: neither side's ORM holds both ends, so no decorator in
|
|
49
|
+
* either codebase can assert them. That is a curation act in the console, and it
|
|
50
|
+
* needs a route that does not exist yet.
|
|
34
51
|
*/
|
|
35
52
|
export declare function CatalogProperty(options?: CatalogPropertyOptions): PropertyDecorator;
|
|
36
53
|
export declare function readTypeOptions(target: unknown): CatalogTypeOptions;
|
|
@@ -27,6 +27,23 @@ function CatalogType(options = {}) {
|
|
|
27
27
|
* Enriches one property. Everything it sets is tier 0 — the overlay can
|
|
28
28
|
* override any of it at runtime without a migration, which is precisely why
|
|
29
29
|
* these live in metadata rather than in the column definition.
|
|
30
|
+
*
|
|
31
|
+
* **This is also how a relation is enriched, and there is deliberately no
|
|
32
|
+
* `@CatalogRelation`.** The metadata is keyed by property name, and a
|
|
33
|
+
* `@ManyToOne` is a property; the registry looks the options up before it
|
|
34
|
+
* decides whether the field is a scalar or a link, so
|
|
35
|
+
* `@CatalogProperty({ displayName: 'Home base' })` on `Mvr.base` labels the link
|
|
36
|
+
* exactly as it labels a column. A second decorator would be a synonym for this
|
|
37
|
+
* one.
|
|
38
|
+
*
|
|
39
|
+
* A decorator that declared a relation *outright* — target, kind, join column —
|
|
40
|
+
* was considered and rejected twice over. Everything an ORM models is already
|
|
41
|
+
* derived, and a hand-written line that could restate it is a line that can
|
|
42
|
+
* disagree with the schema, which is the one thing this model does not allow.
|
|
43
|
+
* And the links an ORM genuinely cannot see are, in practice, the ones that
|
|
44
|
+
* cross applications: neither side's ORM holds both ends, so no decorator in
|
|
45
|
+
* either codebase can assert them. That is a curation act in the console, and it
|
|
46
|
+
* needs a route that does not exist yet.
|
|
30
47
|
*/
|
|
31
48
|
function CatalogProperty(options = {}) {
|
|
32
49
|
return (target, propertyKey) => {
|
|
@@ -291,6 +291,45 @@ export interface PromotableObjectType {
|
|
|
291
291
|
unit?: string;
|
|
292
292
|
classification?: string;
|
|
293
293
|
}>;
|
|
294
|
+
/**
|
|
295
|
+
* The links the type declares, in the shape they are stored in.
|
|
296
|
+
*
|
|
297
|
+
* Absent from this interface until it was noticed that a promoted type arrived
|
|
298
|
+
* complete in every visible way — right properties, right table, right owner —
|
|
299
|
+
* and sat in the target's graph as an island. Nothing errored, nothing was
|
|
300
|
+
* reported, and the plan the operator approved said "nothing to promote" for a
|
|
301
|
+
* release whose only content was a link.
|
|
302
|
+
*
|
|
303
|
+
* Spelled out here rather than imported from the store package, which is the
|
|
304
|
+
* same reason `properties` is: the dependency runs the other way, and this
|
|
305
|
+
* side is the one both a MikroORM environment and anything else that ever
|
|
306
|
+
* holds a catalog have to satisfy. `kind` is a bare `string` for the reason the
|
|
307
|
+
* stored row gives — what comes back out of a JSON column is whatever some
|
|
308
|
+
* earlier version of some publisher put in, and it is narrowed where it is
|
|
309
|
+
* read, not asserted here.
|
|
310
|
+
*
|
|
311
|
+
* Optional, and read as `[]` wherever it is used. An environment whose rows
|
|
312
|
+
* predate the relations column holds `NULL` there, and requiring the field
|
|
313
|
+
* would force every hand-built promotable set to name something it has nothing
|
|
314
|
+
* to say about. "Absent" and "empty" are deliberately the same statement here,
|
|
315
|
+
* unlike on the publish wire: this shape is built by `readPromotable` in this
|
|
316
|
+
* process rather than arriving from a client of unknown vintage, and the plan
|
|
317
|
+
* an operator approves has to describe exactly what the apply will do. See
|
|
318
|
+
* `promoteType`.
|
|
319
|
+
*/
|
|
320
|
+
relations?: Array<{
|
|
321
|
+
name: string;
|
|
322
|
+
displayName: string;
|
|
323
|
+
description?: string;
|
|
324
|
+
kind: string;
|
|
325
|
+
targetType: string;
|
|
326
|
+
localKey?: string;
|
|
327
|
+
nullable: boolean;
|
|
328
|
+
hidden: boolean;
|
|
329
|
+
position: number;
|
|
330
|
+
owner: boolean;
|
|
331
|
+
inverseName?: string;
|
|
332
|
+
}>;
|
|
294
333
|
}
|
|
295
334
|
export interface PromotableTransform {
|
|
296
335
|
id: string;
|
|
@@ -732,5 +732,55 @@ function diffObjectType(existing, incoming) {
|
|
|
732
732
|
to: gone,
|
|
733
733
|
});
|
|
734
734
|
}
|
|
735
|
+
diffs.push(...diffRelations(existing, incoming));
|
|
736
|
+
return diffs;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The links, compared the same way the properties above are — and named
|
|
740
|
+
* differently on purpose.
|
|
741
|
+
*
|
|
742
|
+
* **Why this exists at all.** Without it a promotion whose only difference is a
|
|
743
|
+
* link reports `unchanged`, so the plan says there is nothing to promote and the
|
|
744
|
+
* apply, driven by that plan, does nothing. The change is real, the operator is
|
|
745
|
+
* shown an empty diff, and the link stays behind in dev. A plan is what somebody
|
|
746
|
+
* approves; a change invisible in the plan is a change nobody approved.
|
|
747
|
+
*
|
|
748
|
+
* **`relations.removed`, not `relations.absentFromSource`.** The properties
|
|
749
|
+
* above borrow the softer word because nothing acts on them: `ensureType` is
|
|
750
|
+
* additive, so a column that vanished from the source keeps its data in the
|
|
751
|
+
* target. A link that vanished really is deleted there — `promoteType` assigns
|
|
752
|
+
* the source's links rather than merging them, because a link holds no data and
|
|
753
|
+
* keeping one the source deliberately dropped means the target asserts a join
|
|
754
|
+
* the schema no longer has. The field name has to say which of those two a
|
|
755
|
+
* reviewer is looking at.
|
|
756
|
+
*
|
|
757
|
+
* **`to` repeats the removed names rather than being empty.** The fingerprint an
|
|
758
|
+
* approval is compared against hashes each field's `to` value; an empty one
|
|
759
|
+
* would make "drops the link to Base" and "drops the link to Depot" hash
|
|
760
|
+
* identically, so a plan approved for one would be applicable as the other.
|
|
761
|
+
*/
|
|
762
|
+
function diffRelations(existing, incoming) {
|
|
763
|
+
const diffs = [];
|
|
764
|
+
// `?? []` on both sides, so an environment predating the relations column
|
|
765
|
+
// compares as holding none rather than as differing from everything.
|
|
766
|
+
const before = new Map((existing?.relations ?? []).map((relation) => [relation.name, relation]));
|
|
767
|
+
const after = new Map((incoming.relations ?? []).map((relation) => [relation.name, relation]));
|
|
768
|
+
const added = [...after.keys()].filter((name) => !before.has(name));
|
|
769
|
+
const changed = [...after.entries()]
|
|
770
|
+
.filter(([name, relation]) => {
|
|
771
|
+
const previous = before.get(name);
|
|
772
|
+
return previous !== undefined && stable(previous) !== stable(relation);
|
|
773
|
+
})
|
|
774
|
+
.map(([name]) => name);
|
|
775
|
+
const removed = [...before.keys()].filter((name) => !after.has(name));
|
|
776
|
+
if (added.length) {
|
|
777
|
+
diffs.push({ field: 'relations.added', from: [], to: added });
|
|
778
|
+
}
|
|
779
|
+
if (changed.length) {
|
|
780
|
+
diffs.push({ field: 'relations.changed', from: changed, to: changed });
|
|
781
|
+
}
|
|
782
|
+
if (removed.length) {
|
|
783
|
+
diffs.push({ field: 'relations.removed', from: removed, to: removed });
|
|
784
|
+
}
|
|
735
785
|
return diffs;
|
|
736
786
|
}
|
|
@@ -84,6 +84,25 @@ export interface CatalogConnector {
|
|
|
84
84
|
* view atomically. `incremental` reads only what changed since the last run
|
|
85
85
|
* and carries the rest forward, which is cheaper but needs the source to
|
|
86
86
|
* offer a watermark and the type to have a primary key to merge on.
|
|
87
|
+
*
|
|
88
|
+
* **`incremental` is blind to deletes, and that is structural rather than a
|
|
89
|
+
* gap in any particular fetcher.** A run asks its source for what changed
|
|
90
|
+
* since a watermark; a row physically removed from the source never changes
|
|
91
|
+
* again, so it is never returned again, so the carry-forward copies it into
|
|
92
|
+
* every subsequent snapshot indefinitely. Nothing goes wrong at any single
|
|
93
|
+
* step — the catalog simply never finds out, and every count and dashboard
|
|
94
|
+
* built on the type is quietly wrong from then on.
|
|
95
|
+
*
|
|
96
|
+
* Because that failure is silent, the pipeline **refuses an incremental load
|
|
97
|
+
* of a type for which no reconciliation strategy has been declared**: a full
|
|
98
|
+
* read on an interval, a source that soft-deletes where the watermark can see
|
|
99
|
+
* it, or an explicit "stale rows are acceptable here, because …". The
|
|
100
|
+
* declaration is per object type and lives in the host's
|
|
101
|
+
* `CATALOG_LOAD_EXPECTATIONS` (see `load-expectations.ts` in
|
|
102
|
+
* `@dudousxd/nestjs-catalog-pipeline`), because it is a statement about the
|
|
103
|
+
* data rather than about the connector reading it — the same type loaded by a
|
|
104
|
+
* workflow sink or by an application POSTing to the publish API has exactly
|
|
105
|
+
* the same problem.
|
|
87
106
|
*/
|
|
88
107
|
mode?: 'full' | 'incremental';
|
|
89
108
|
/**
|
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
import type { CatalogGraph, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
|
|
2
|
+
/**
|
|
3
|
+
* The ontology, as nodes and lines. **The one implementation of the edge rule.**
|
|
4
|
+
*
|
|
5
|
+
* Three rules, and all three exist because the naive version of each drew a
|
|
6
|
+
* picture that was wrong in a way nobody would notice:
|
|
7
|
+
*
|
|
8
|
+
* 1. **One edge per link.** A link declared at both ends produces two rows, and
|
|
9
|
+
* keying the de-duplication on the property name only caught the case where
|
|
10
|
+
* both ends happened to be spelled alike — so `Mvr.base` plus `Base.mvrs`,
|
|
11
|
+
* the ordinary shape of a foreign key, drew two lines between the same pair
|
|
12
|
+
* of nodes. See {@link linkKey} for how the two rows are recognised as one.
|
|
13
|
+
* 2. **Drawn from the end that holds the key**, so the arrow points the way a
|
|
14
|
+
* join is written. Both ends are collected before either is chosen, because
|
|
15
|
+
* otherwise the direction depends on which type was discovered first.
|
|
16
|
+
* 3. **No edge to a node that is not here.** An edge promises a node the reader
|
|
17
|
+
* can open, and a target this catalog does not hold has none.
|
|
18
|
+
*
|
|
19
|
+
* Hidden relations are deliberately still drawn. Hiding is a statement about a
|
|
20
|
+
* table cell; a graph that quietly dropped edges would be a picture nobody could
|
|
21
|
+
* read as complete, which is the only thing a graph is for.
|
|
22
|
+
*
|
|
23
|
+
* WHY IT LIVES HERE, ON THE BASE CLASS'S OWN MODULE
|
|
24
|
+
* -------------------------------------------------
|
|
25
|
+
* Both registries had a copy — one derives the model from ORM metadata, the
|
|
26
|
+
* other reads it out of the database — in different packages, each carrying a
|
|
27
|
+
* comment asking the next person to change them together. A comment is not a
|
|
28
|
+
* mechanism, and a divergence here would be invisible: the two screens would
|
|
29
|
+
* simply disagree about how many links exist, and the copy that regressed would
|
|
30
|
+
* look exactly like the original bug (two edges per foreign key). The graph is a
|
|
31
|
+
* pure function of the snapshot, which is the one thing every registry already
|
|
32
|
+
* has to produce, so there was never anything for a subclass to decide.
|
|
33
|
+
*/
|
|
34
|
+
export declare function buildCatalogGraph(types: CatalogObjectTypeDef[]): CatalogGraph;
|
|
2
35
|
/**
|
|
3
36
|
* What the catalog knows about your types, however it came to know it.
|
|
4
37
|
*
|
|
@@ -13,7 +46,19 @@ import type { CatalogGraph, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapsho
|
|
|
13
46
|
export declare abstract class CatalogRegistry {
|
|
14
47
|
abstract getSnapshot(): CatalogSnapshot;
|
|
15
48
|
abstract getType(name: string): CatalogObjectTypeDef | undefined;
|
|
16
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The ontology, drawn. Concrete, and the only implementation either registry
|
|
51
|
+
* runs.
|
|
52
|
+
*
|
|
53
|
+
* Not abstract because nothing about it varies: every edge and every node is
|
|
54
|
+
* read off the snapshot, and the snapshot is the abstract thing. The two
|
|
55
|
+
* registries did each override it with the same forty lines, which is how the
|
|
56
|
+
* edge rule came to exist twice in two packages — the failure this being a
|
|
57
|
+
* concrete method prevents. Where a registry *delegates* rather than derives
|
|
58
|
+
* (`RoutingCatalogRegistry` hands the whole call to whichever environment the
|
|
59
|
+
* request named) overriding is still right; deriving it a second time is not.
|
|
60
|
+
*/
|
|
61
|
+
getGraph(): CatalogGraph;
|
|
17
62
|
/** Presentation-only edits. Never a schema change. */
|
|
18
63
|
abstract patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>): Promise<CatalogObjectTypeDef | undefined>;
|
|
19
64
|
abstract patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string]): Promise<CatalogObjectTypeDef | undefined>;
|
|
@@ -1,6 +1,93 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CatalogRegistry = void 0;
|
|
4
|
+
exports.buildCatalogGraph = buildCatalogGraph;
|
|
5
|
+
/**
|
|
6
|
+
* A key both ends of one link agree on, so the graph can draw it once.
|
|
7
|
+
*
|
|
8
|
+
* The owning end names the link — `Mvr.base` — and the inverse end, which knows
|
|
9
|
+
* the owner's property through `mappedBy`, arrives at the same string. That is
|
|
10
|
+
* the whole trick, and it is why `inverseName` is carried on the def at all.
|
|
11
|
+
*
|
|
12
|
+
* The fallback covers metadata that names neither end of the pair: both rows
|
|
13
|
+
* then reduce to the unordered pair plus the property name, which collapses the
|
|
14
|
+
* symmetric case (two `m:n` sides spelled alike) and leaves genuinely different
|
|
15
|
+
* names as two links. Guessing harder than that would mean pairing links by
|
|
16
|
+
* shape, and drawing one line where the schema has two is the worse error.
|
|
17
|
+
*/
|
|
18
|
+
function linkKey(holder, relation) {
|
|
19
|
+
if (relation.owner)
|
|
20
|
+
return `${holder}.${relation.name}`;
|
|
21
|
+
if (relation.inverseName)
|
|
22
|
+
return `${relation.targetType}.${relation.inverseName}`;
|
|
23
|
+
return `${[holder, relation.targetType].sort().join('::')}::${relation.name}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The ontology, as nodes and lines. **The one implementation of the edge rule.**
|
|
27
|
+
*
|
|
28
|
+
* Three rules, and all three exist because the naive version of each drew a
|
|
29
|
+
* picture that was wrong in a way nobody would notice:
|
|
30
|
+
*
|
|
31
|
+
* 1. **One edge per link.** A link declared at both ends produces two rows, and
|
|
32
|
+
* keying the de-duplication on the property name only caught the case where
|
|
33
|
+
* both ends happened to be spelled alike — so `Mvr.base` plus `Base.mvrs`,
|
|
34
|
+
* the ordinary shape of a foreign key, drew two lines between the same pair
|
|
35
|
+
* of nodes. See {@link linkKey} for how the two rows are recognised as one.
|
|
36
|
+
* 2. **Drawn from the end that holds the key**, so the arrow points the way a
|
|
37
|
+
* join is written. Both ends are collected before either is chosen, because
|
|
38
|
+
* otherwise the direction depends on which type was discovered first.
|
|
39
|
+
* 3. **No edge to a node that is not here.** An edge promises a node the reader
|
|
40
|
+
* can open, and a target this catalog does not hold has none.
|
|
41
|
+
*
|
|
42
|
+
* Hidden relations are deliberately still drawn. Hiding is a statement about a
|
|
43
|
+
* table cell; a graph that quietly dropped edges would be a picture nobody could
|
|
44
|
+
* read as complete, which is the only thing a graph is for.
|
|
45
|
+
*
|
|
46
|
+
* WHY IT LIVES HERE, ON THE BASE CLASS'S OWN MODULE
|
|
47
|
+
* -------------------------------------------------
|
|
48
|
+
* Both registries had a copy — one derives the model from ORM metadata, the
|
|
49
|
+
* other reads it out of the database — in different packages, each carrying a
|
|
50
|
+
* comment asking the next person to change them together. A comment is not a
|
|
51
|
+
* mechanism, and a divergence here would be invisible: the two screens would
|
|
52
|
+
* simply disagree about how many links exist, and the copy that regressed would
|
|
53
|
+
* look exactly like the original bug (two edges per foreign key). The graph is a
|
|
54
|
+
* pure function of the snapshot, which is the one thing every registry already
|
|
55
|
+
* has to produce, so there was never anything for a subclass to decide.
|
|
56
|
+
*/
|
|
57
|
+
function buildCatalogGraph(types) {
|
|
58
|
+
const byLink = new Map();
|
|
59
|
+
for (const type of types) {
|
|
60
|
+
for (const relation of type.relations) {
|
|
61
|
+
if (!relation.targetPublished)
|
|
62
|
+
continue;
|
|
63
|
+
const key = linkKey(type.name, relation);
|
|
64
|
+
const seen = byLink.get(key);
|
|
65
|
+
// Replace only when this row is the owning one and the row already held
|
|
66
|
+
// is not: anything else keeps the first, so the edge order stays the type
|
|
67
|
+
// order rather than shuffling with every rebuild.
|
|
68
|
+
if (seen && (seen.relation.owner || !relation.owner))
|
|
69
|
+
continue;
|
|
70
|
+
byLink.set(key, { holder: type.name, relation });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
nodes: types.map((t) => ({
|
|
75
|
+
id: t.name,
|
|
76
|
+
label: t.displayName,
|
|
77
|
+
group: t.group,
|
|
78
|
+
icon: t.icon,
|
|
79
|
+
propertyCount: t.properties.length,
|
|
80
|
+
relationCount: t.relations.length,
|
|
81
|
+
})),
|
|
82
|
+
edges: [...byLink.values()].map(({ holder, relation }) => ({
|
|
83
|
+
id: `${holder}.${relation.name}`,
|
|
84
|
+
source: holder,
|
|
85
|
+
target: relation.targetType,
|
|
86
|
+
label: relation.displayName,
|
|
87
|
+
kind: relation.kind,
|
|
88
|
+
})),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
4
91
|
/**
|
|
5
92
|
* What the catalog knows about your types, however it came to know it.
|
|
6
93
|
*
|
|
@@ -13,5 +100,20 @@ exports.CatalogRegistry = void 0;
|
|
|
13
100
|
* data it was handed. Everything above this line works the same either way.
|
|
14
101
|
*/
|
|
15
102
|
class CatalogRegistry {
|
|
103
|
+
/**
|
|
104
|
+
* The ontology, drawn. Concrete, and the only implementation either registry
|
|
105
|
+
* runs.
|
|
106
|
+
*
|
|
107
|
+
* Not abstract because nothing about it varies: every edge and every node is
|
|
108
|
+
* read off the snapshot, and the snapshot is the abstract thing. The two
|
|
109
|
+
* registries did each override it with the same forty lines, which is how the
|
|
110
|
+
* edge rule came to exist twice in two packages — the failure this being a
|
|
111
|
+
* concrete method prevents. Where a registry *delegates* rather than derives
|
|
112
|
+
* (`RoutingCatalogRegistry` hands the whole call to whichever environment the
|
|
113
|
+
* request named) overriding is still right; deriving it a second time is not.
|
|
114
|
+
*/
|
|
115
|
+
getGraph() {
|
|
116
|
+
return buildCatalogGraph(this.getSnapshot().types);
|
|
117
|
+
}
|
|
16
118
|
}
|
|
17
119
|
exports.CatalogRegistry = CatalogRegistry;
|
|
@@ -4,7 +4,7 @@ import { type OnModuleInit } from '@nestjs/common';
|
|
|
4
4
|
import { type CatalogModuleOptions } from './catalog.options';
|
|
5
5
|
import type { CatalogOverlayStore } from './catalog.overlay-store';
|
|
6
6
|
import { CatalogRegistry } from './catalog.registry.base';
|
|
7
|
-
import type {
|
|
7
|
+
import type { CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
|
|
8
8
|
export declare class MikroOrmCatalogRegistry extends CatalogRegistry implements OnModuleInit {
|
|
9
9
|
private readonly orm;
|
|
10
10
|
private readonly options;
|
|
@@ -31,7 +31,6 @@ export declare class MikroOrmCatalogRegistry extends CatalogRegistry implements
|
|
|
31
31
|
* generic object endpoint by guessing its name.
|
|
32
32
|
*/
|
|
33
33
|
getEntityClass(name: string): EntityClass<Record<string, unknown>> | undefined;
|
|
34
|
-
getGraph(): CatalogGraph;
|
|
35
34
|
/** Tier-0 edit on a type. Never touches the database. */
|
|
36
35
|
patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>): Promise<CatalogObjectTypeDef | undefined>;
|
|
37
36
|
/** Tier-0 edit on a property. Never touches the database. */
|
package/dist/catalog.registry.js
CHANGED
|
@@ -27,6 +27,30 @@ const RELATION_KINDS = ['1:1', '1:m', 'm:1', 'm:n'];
|
|
|
27
27
|
function isRelationKind(kind) {
|
|
28
28
|
return RELATION_KINDS.includes(kind);
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Which end of the link holds the key.
|
|
32
|
+
*
|
|
33
|
+
* Not simply `prop.owner`, and the difference matters. MikroORM sets that flag
|
|
34
|
+
* while resolving the *pair*, so it is dependable for `1:1` and `m:n` — where
|
|
35
|
+
* either side could plausibly own the key and only the mapping says which — and
|
|
36
|
+
* beside the point for the two kinds that have no choice: a `m:1` is the many
|
|
37
|
+
* end and therefore always holds the column, a `1:m` is the one end and
|
|
38
|
+
* therefore never does. Deriving those two from the kind rather than from a flag
|
|
39
|
+
* also means metadata assembled by hand (an `EntitySchema`, or a test) answers
|
|
40
|
+
* correctly without having to know the flag exists.
|
|
41
|
+
*
|
|
42
|
+
* `mappedBy` is checked first because it is unambiguous wherever it appears: the
|
|
43
|
+
* ORM only ever writes it on the inverse side.
|
|
44
|
+
*/
|
|
45
|
+
function isOwningSide(prop, kind) {
|
|
46
|
+
if (prop.mappedBy)
|
|
47
|
+
return false;
|
|
48
|
+
if (kind === '1:m')
|
|
49
|
+
return false;
|
|
50
|
+
if (kind === 'm:1')
|
|
51
|
+
return true;
|
|
52
|
+
return Boolean(prop.owner);
|
|
53
|
+
}
|
|
30
54
|
/** Classify one type name. Returns "unknown" when nothing matches. */
|
|
31
55
|
function classify(raw) {
|
|
32
56
|
const t = raw.toLowerCase();
|
|
@@ -154,41 +178,6 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
154
178
|
this.rebuild();
|
|
155
179
|
return this.entityClasses.get(name);
|
|
156
180
|
}
|
|
157
|
-
getGraph() {
|
|
158
|
-
const snapshot = this.getSnapshot();
|
|
159
|
-
const known = new Set(snapshot.types.map((t) => t.name));
|
|
160
|
-
const nodes = snapshot.types.map((t) => ({
|
|
161
|
-
id: t.name,
|
|
162
|
-
label: t.displayName,
|
|
163
|
-
group: t.group,
|
|
164
|
-
icon: t.icon,
|
|
165
|
-
propertyCount: t.properties.length,
|
|
166
|
-
relationCount: t.relations.length,
|
|
167
|
-
}));
|
|
168
|
-
// Both ends of a relation are declared, so every link shows up twice. Keep
|
|
169
|
-
// one edge per unordered pair per name so the graph does not double up.
|
|
170
|
-
const seen = new Set();
|
|
171
|
-
const edges = [];
|
|
172
|
-
for (const type of snapshot.types) {
|
|
173
|
-
for (const relation of type.relations) {
|
|
174
|
-
if (!known.has(relation.targetType))
|
|
175
|
-
continue;
|
|
176
|
-
const pair = [type.name, relation.targetType].sort().join('::');
|
|
177
|
-
const key = `${pair}::${relation.name}`;
|
|
178
|
-
if (seen.has(key))
|
|
179
|
-
continue;
|
|
180
|
-
seen.add(key);
|
|
181
|
-
edges.push({
|
|
182
|
-
id: `${type.name}.${relation.name}`,
|
|
183
|
-
source: type.name,
|
|
184
|
-
target: relation.targetType,
|
|
185
|
-
label: relation.displayName,
|
|
186
|
-
kind: relation.kind,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
return { nodes, edges };
|
|
191
|
-
}
|
|
192
181
|
/** Tier-0 edit on a type. Never touches the database. */
|
|
193
182
|
async patchType(typeName, patch) {
|
|
194
183
|
const type = this.getType(typeName);
|
|
@@ -245,13 +234,21 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
245
234
|
// here yields an empty array and a catalog that silently contains nothing.
|
|
246
235
|
const all = this.orm.getMetadata().getAll();
|
|
247
236
|
const types = [];
|
|
237
|
+
// Two passes, because a relation cannot be described without knowing the
|
|
238
|
+
// whole catalog: whether its target is published is a fact about the
|
|
239
|
+
// catalog, not about the entity being read, and a single pass would answer
|
|
240
|
+
// it differently depending on discovery order.
|
|
248
241
|
this.entityClasses.clear();
|
|
242
|
+
const included = [];
|
|
249
243
|
for (const meta of all.values()) {
|
|
250
244
|
if (!this.shouldInclude(meta))
|
|
251
245
|
continue;
|
|
252
246
|
this.entityClasses.set(meta.className, meta.class);
|
|
253
|
-
|
|
247
|
+
included.push(meta);
|
|
254
248
|
}
|
|
249
|
+
const published = new Set(included.map((meta) => meta.className));
|
|
250
|
+
for (const meta of included)
|
|
251
|
+
types.push(this.buildType(meta, published));
|
|
255
252
|
types.sort((a, b) => a.group.localeCompare(b.group) || a.displayName.localeCompare(b.displayName));
|
|
256
253
|
this.version += 1;
|
|
257
254
|
this.snapshot = {
|
|
@@ -279,7 +276,7 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
279
276
|
return false;
|
|
280
277
|
return true;
|
|
281
278
|
}
|
|
282
|
-
buildType(meta) {
|
|
279
|
+
buildType(meta, published) {
|
|
283
280
|
const entityClass = meta.class;
|
|
284
281
|
const declared = (0, catalog_decorators_1.readTypeOptions)(entityClass);
|
|
285
282
|
const declaredProps = (0, catalog_decorators_1.readPropertyOptions)(entityClass);
|
|
@@ -298,16 +295,28 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
298
295
|
const fromOverlay = overlayProps[prop.name];
|
|
299
296
|
const { displayName, description, hidden, order } = resolveFieldPresentation(prop.name, index, fromDecorator, fromOverlay);
|
|
300
297
|
if (isRelationKind(prop.kind)) {
|
|
298
|
+
const targetType = prop.targetMeta?.className ?? String(prop.type);
|
|
301
299
|
relations.push({
|
|
302
300
|
name: prop.name,
|
|
303
301
|
displayName,
|
|
304
302
|
description,
|
|
305
303
|
kind: prop.kind,
|
|
306
|
-
targetType
|
|
304
|
+
targetType,
|
|
307
305
|
localKey: prop.fieldNames?.[0],
|
|
308
306
|
nullable: Boolean(prop.nullable),
|
|
309
307
|
hidden,
|
|
310
308
|
order,
|
|
309
|
+
owner: isOwningSide(prop, prop.kind),
|
|
310
|
+
// Either name identifies the same thing — the property at the other
|
|
311
|
+
// end — and only one of them is ever set, on the side the ORM decided
|
|
312
|
+
// is inverse. Read as one field because callers pairing the two ends
|
|
313
|
+
// do not care which of the two spellings carried it.
|
|
314
|
+
inverseName: prop.mappedBy || prop.inversedBy || undefined,
|
|
315
|
+
targetPublished: published.has(targetType),
|
|
316
|
+
// A relation is enriched on the same terms as a scalar: somebody said
|
|
317
|
+
// something about it. Structure is not enrichment — every relation
|
|
318
|
+
// here was derived, so its existence proves nothing about curation.
|
|
319
|
+
enriched: Boolean(fromDecorator || fromOverlay),
|
|
311
320
|
});
|
|
312
321
|
return;
|
|
313
322
|
}
|
|
@@ -341,7 +350,12 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
341
350
|
primaryKey: meta.primaryKeys ?? [],
|
|
342
351
|
enriched: Object.keys(declared).length > 0 ||
|
|
343
352
|
Object.keys(overlay).length > 0 ||
|
|
344
|
-
properties.some((p) => p.enriched)
|
|
353
|
+
properties.some((p) => p.enriched) ||
|
|
354
|
+
// Relations count too. A type whose only human input is "this link is
|
|
355
|
+
// called Home base" has been worked on, and leaving it out of the tally
|
|
356
|
+
// put it back on the "nobody has named this" list the curator uses to
|
|
357
|
+
// decide what to do next.
|
|
358
|
+
relations.some((r) => r.enriched),
|
|
345
359
|
properties,
|
|
346
360
|
relations,
|
|
347
361
|
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { type CatalogModuleOptions } from './catalog.options';
|
|
2
|
+
import type { CatalogPrincipal } from './catalog.principal';
|
|
2
3
|
import { type CatalogQueryRelation, type CatalogQueryResult } from './catalog.query';
|
|
3
4
|
import { CatalogRegistry } from './catalog.registry.base';
|
|
4
5
|
import { type CatalogReadStore, type SnapshotRef } from './catalog.store';
|
|
5
6
|
import type { CatalogGraph, CatalogObjectPage, CatalogObjectQuery, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
|
|
6
7
|
import { type AuditQuery, type CatalogAuditEvent, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, type EmbeddedChart, type EmbeddedChartPlacement, type EmbeddedDashboard, type SaveQueryInput, type SavedQuery } from './catalog.workspace';
|
|
8
|
+
import type { CatalogSearchResult } from './search.types';
|
|
7
9
|
/**
|
|
8
10
|
* Reads objects of any catalogued type through one endpoint.
|
|
9
11
|
*
|
|
@@ -155,4 +157,19 @@ export declare class CatalogService {
|
|
|
155
157
|
/** A whole dashboard, every chart resolved. */
|
|
156
158
|
embedDashboard(dashboardId: string): Promise<EmbeddedDashboard>;
|
|
157
159
|
listEvents(query: AuditQuery): Promise<CatalogAuditEvent[]>;
|
|
160
|
+
/**
|
|
161
|
+
* Everything matching `term` that this principal may see.
|
|
162
|
+
*
|
|
163
|
+
* @param principal the caller, when the host resolved one. **Optional, and its
|
|
164
|
+
* absence filters nothing** — the declare-and-enforce split written out above
|
|
165
|
+
* `mayWrite` in `catalog.principal.ts` means this library never resolves a
|
|
166
|
+
* principal itself. In a deployment with no guard, `GET /catalog` already
|
|
167
|
+
* hands over the whole snapshot, so search is exactly as open as what is
|
|
168
|
+
* already there and strictly narrower the moment a principal appears. See
|
|
169
|
+
* {@link visibleToPrincipal}.
|
|
170
|
+
*/
|
|
171
|
+
search(term: string, options?: {
|
|
172
|
+
principal?: CatalogPrincipal;
|
|
173
|
+
limit?: number;
|
|
174
|
+
}): Promise<CatalogSearchResult>;
|
|
158
175
|
}
|