@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.
@@ -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
- abstract getGraph(): CatalogGraph;
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 { CatalogGraph, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
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. */
@@ -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
- types.push(this.buildType(meta));
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: prop.targetMeta?.className ?? String(prop.type),
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
  }