@dudousxd/nestjs-catalog 0.7.0 → 0.9.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'),
@@ -14,7 +14,7 @@
14
14
  */
15
15
  export declare const CATALOG_LIB = "catalog";
16
16
  /** Every event name this package emits. Exported so a watcher can claim them. */
17
- export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed", "query.shared", "dashboard.shared"];
17
+ export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "overlay.reset", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed", "query.shared", "dashboard.shared"];
18
18
  export type CatalogEvent = (typeof CATALOG_EVENTS)[number];
19
19
  /**
20
20
  * Where each event sits in the life of one load.
@@ -81,6 +81,77 @@ export interface CatalogEventPayloads {
81
81
  property?: string;
82
82
  changed: string[];
83
83
  };
84
+ /**
85
+ * The whole overlay was discarded — every curated label, description, unit,
86
+ * order, hidden flag and classification in the catalog, in one request.
87
+ *
88
+ * An event of its own rather than a `type.curated` with the name left off.
89
+ * That payload leads with `typeName`, and a recorder lifts it into an indexed
90
+ * column; a reset has no single type, so it would land as a curation edit
91
+ * belonging to no type, indistinguishable from a malformed one. It is also a
92
+ * different act: `type.curated` records a decision about one column, this
93
+ * records the destruction of every such decision. Without it the trail could
94
+ * say who renamed one column and not who reverted every name at once — and
95
+ * both are `catalog:curate`, so the same curator can do either.
96
+ *
97
+ * **The classifications are why this is not merely tidy.** A classification is
98
+ * what `visibleToPrincipal` filters search results on, so a reset silently
99
+ * re-admits every classified property's *name* to searches by principals who
100
+ * could not see it an instant earlier. That is a change in who can see what,
101
+ * made by a route the controller documents as presentation-only.
102
+ *
103
+ * WHAT IT CARRIES, AND WHAT IT DELIBERATELY DOES NOT
104
+ * --------------------------------------------------
105
+ * The overlay is discarded rather than versioned, so nothing can be looked up
106
+ * afterwards: what is not in this payload is nowhere. That argues for carrying
107
+ * all of it, and all of it is the wrong answer — an audit row holding a
108
+ * verbatim copy of the overlay is a backup, and a backup nobody designed: no
109
+ * restore path, no retention policy of its own, and a JSON column that grows
110
+ * with the catalog. It would be read as one, too. The first person who needed
111
+ * it would find it, and the second would rely on it.
112
+ *
113
+ * So: a summary, drawn where the reader's question stops being "what did I
114
+ * lose" and starts being "give it back".
115
+ *
116
+ * - {@link typeNames}, because "somebody reset the catalog" is nearly useless
117
+ * six months later and "was the work on `Dispute` in it" is what is actually
118
+ * asked. Bounded by how many types anyone had curated.
119
+ * - {@link properties}, the scale of it as one number. The property *names* are
120
+ * where a summary would turn into the dump.
121
+ * - {@link classifications} in full, values included, despite that line. They
122
+ * are the one part of the overlay whose loss changes what the catalog shows
123
+ * to whom, they are a small subset of it, and re-typing them is the only
124
+ * recovery anybody can perform.
125
+ *
126
+ * **No `principalId`, and the absence is a limit rather than a decision that
127
+ * the actor does not matter.** `resetOverlay()` takes no principal, the route
128
+ * that calls it resolves none, and `RoutingCatalogRegistry` forwards the call
129
+ * by hand — so a field here would be `undefined` on every row, and an audit
130
+ * table lifts `principalId` into a column where empty reads as "nobody did
131
+ * this" rather than "this was not captured". `type.curated` has the same gap.
132
+ * Closing it means threading a principal through the controller, the service
133
+ * and every registry, which is a change to those, not a field on this payload.
134
+ *
135
+ * Emitted even when the overlay was empty, with zeroes. A trail that recorded
136
+ * only destructive resets cannot tell "nobody pressed it" from "somebody
137
+ * pressed it and nothing was there", and the second is worth seeing.
138
+ */
139
+ 'overlay.reset': {
140
+ /** Every type that carried curation, so the trail names what was lost. */
141
+ typeNames: string[];
142
+ /** How many per-property entries went with them, across every type. */
143
+ properties: number;
144
+ /**
145
+ * Every classification that stopped applying, with its value — because that
146
+ * is what somebody restoring one needs, and after the reset there is nowhere
147
+ * left to read it.
148
+ */
149
+ classifications: Array<{
150
+ typeName: string;
151
+ property: string;
152
+ classification: string;
153
+ }>;
154
+ };
84
155
  /** A connector began pulling. */
85
156
  'connector.run.started': {
86
157
  connectorId: string;
@@ -27,6 +27,7 @@ exports.CATALOG_EVENTS = [
27
27
  'snapshot.committed',
28
28
  'snapshot.dropped',
29
29
  'type.curated',
30
+ 'overlay.reset',
30
31
  'connector.run.started',
31
32
  'connector.run.finished',
32
33
  'transform.changed',
@@ -67,6 +68,11 @@ exports.CATALOG_EVENT_PHASE = {
67
68
  // be complete is exactly the mechanism that will fail the build the day a new
68
69
  // event is added and nobody thinks about where it belongs.
69
70
  'type.curated': 2,
71
+ // The other curation event, ranked with the one it undoes. It carries no
72
+ // snapshot id either — a reset is not part of any load — so like the rank
73
+ // above this is never consulted, and it is written out because the `Record`
74
+ // has to be complete.
75
+ 'overlay.reset': 2,
70
76
  // Sharing carries no snapshot id either, for the same reason curation does
71
77
  // not: it is a standalone act on a saved query or a board, not a step of any
72
78
  // load. So these ranks are never consulted, and they are written out for the
@@ -17,7 +17,28 @@ export declare class FileCatalogOverlayStore implements CatalogOverlayStore {
17
17
  load(): Promise<CatalogOverlay>;
18
18
  save(overlay: CatalogOverlay): Promise<void>;
19
19
  }
20
- /** For tests, and for deployments that want the catalog strictly read-only. */
20
+ /**
21
+ * For tests, and for a single-process deployment content to lose every curated
22
+ * value when it restarts.
23
+ *
24
+ * **Not a read-only mode**, which is what this used to offer itself as. Nothing
25
+ * here refuses anything: `save` takes the overlay and keeps it, `PATCH
26
+ * /catalog/types/:name` answers 200 and emits `type.curated`, and the rename is
27
+ * real right up until the process ends. What it is not is *shared* — the overlay
28
+ * lives in one process's heap, so two replicas behind the same load balancer
29
+ * disagree about what a column is called, and which name a curator sees back
30
+ * depends on which pod took their request. A deployment that chose this store
31
+ * because the docblock promised read-only got precisely the writes it was trying
32
+ * to prevent, and got them inconsistently.
33
+ *
34
+ * There is no mode here to turn on instead, because read-only is not a store's
35
+ * decision. The writes arrive on routes that declare `catalog:curate`, and
36
+ * whether a deployment grants that scope is its guard's business — see the
37
+ * declare-and-enforce split in `catalog.route-auth.ts`. A store that threw would
38
+ * turn a policy answer ("you may not curate here") into a 500 from a route the
39
+ * library documents as working, and would put a second mechanism beside the one
40
+ * that already decides.
41
+ */
21
42
  export declare class InMemoryCatalogOverlayStore implements CatalogOverlayStore {
22
43
  private overlay;
23
44
  load(): Promise<CatalogOverlay>;
@@ -13,12 +13,15 @@ class FileCatalogOverlayStore {
13
13
  try {
14
14
  const raw = await (0, promises_1.readFile)(this.path, 'utf8');
15
15
  const parsed = JSON.parse(raw);
16
- if (parsed &&
17
- typeof parsed === 'object' &&
18
- 'types' in parsed &&
19
- typeof parsed.types === 'object') {
16
+ // Narrowed by a guard rather than asserted. The file on disk is edited by
17
+ // hand that is the whole point of a JSON overlay — so its contents are
18
+ // exactly as trustworthy as whoever last opened it, and an assertion here
19
+ // would hand the registry a shape it promised to have rather than one it
20
+ // was checked for. The failure that buys is quiet: `types` arriving as
21
+ // `null` (an object, by `typeof`) or as an array reads as an overlay with
22
+ // no curation, and every label somebody wrote silently stops applying.
23
+ if (isOverlay(parsed))
20
24
  return parsed;
21
- }
22
25
  return { types: {} };
23
26
  }
24
27
  catch {
@@ -31,7 +34,28 @@ class FileCatalogOverlayStore {
31
34
  }
32
35
  }
33
36
  exports.FileCatalogOverlayStore = FileCatalogOverlayStore;
34
- /** For tests, and for deployments that want the catalog strictly read-only. */
37
+ /**
38
+ * For tests, and for a single-process deployment content to lose every curated
39
+ * value when it restarts.
40
+ *
41
+ * **Not a read-only mode**, which is what this used to offer itself as. Nothing
42
+ * here refuses anything: `save` takes the overlay and keeps it, `PATCH
43
+ * /catalog/types/:name` answers 200 and emits `type.curated`, and the rename is
44
+ * real right up until the process ends. What it is not is *shared* — the overlay
45
+ * lives in one process's heap, so two replicas behind the same load balancer
46
+ * disagree about what a column is called, and which name a curator sees back
47
+ * depends on which pod took their request. A deployment that chose this store
48
+ * because the docblock promised read-only got precisely the writes it was trying
49
+ * to prevent, and got them inconsistently.
50
+ *
51
+ * There is no mode here to turn on instead, because read-only is not a store's
52
+ * decision. The writes arrive on routes that declare `catalog:curate`, and
53
+ * whether a deployment grants that scope is its guard's business — see the
54
+ * declare-and-enforce split in `catalog.route-auth.ts`. A store that threw would
55
+ * turn a policy answer ("you may not curate here") into a 500 from a route the
56
+ * library documents as working, and would put a second mechanism beside the one
57
+ * that already decides.
58
+ */
35
59
  class InMemoryCatalogOverlayStore {
36
60
  overlay = { types: {} };
37
61
  async load() {
@@ -42,3 +66,20 @@ class InMemoryCatalogOverlayStore {
42
66
  }
43
67
  }
44
68
  exports.InMemoryCatalogOverlayStore = InMemoryCatalogOverlayStore;
69
+ /**
70
+ * Whether a parsed file is an overlay, checked to the depth that matters.
71
+ *
72
+ * The nesting below `types` is deliberately NOT walked. Every consumer reads it
73
+ * defensively — an entry that is missing, or missing the key it wanted, is the
74
+ * ordinary case for a type nobody has curated — so validating each entry would
75
+ * be re-implementing a tolerance the readers already have, and refusing the
76
+ * whole file over one malformed entry would discard every good one beside it.
77
+ */
78
+ function isOverlay(value) {
79
+ if (!value || typeof value !== 'object')
80
+ return false;
81
+ const types = Reflect.get(value, 'types');
82
+ // Not `typeof types === 'object'`, which admits `null` and arrays. Both parse
83
+ // from a hand-edited file, and both read downstream as "nothing is curated".
84
+ return Boolean(types) && typeof types === 'object' && !Array.isArray(types);
85
+ }
@@ -158,7 +158,28 @@ export interface CatalogTransform {
158
158
  }
159
159
  export interface TransformResult {
160
160
  rows: Array<Record<string, unknown>>;
161
- /** Anything the code logged. Surfaced in the run, never in the rows. */
161
+ /**
162
+ * Anything the code logged. Surfaced in the run, never in the rows.
163
+ *
164
+ * "Anything the code logged" is meant literally, and in whichever language the
165
+ * transform is written: `console.log` and its siblings in JavaScript and
166
+ * TypeScript, `print` and anything written to `sys.stderr` in Python. A
167
+ * transform author's first instinct for finding out what their code is doing
168
+ * has to be the thing that works, because the alternative — an empty panel and
169
+ * no explanation — reads as "my code never ran" rather than as "you used the
170
+ * wrong function".
171
+ *
172
+ * In call order, with the channels interleaved rather than separated: a reader
173
+ * is reconstructing a sequence, and two lists cannot be zipped back together.
174
+ *
175
+ * **Bounded, by the runner, before it is returned.** These are lines user code
176
+ * chose and they cross a durable step boundary into the run record, so an
177
+ * unbounded capture would make the size of a `finishRun` write a property of
178
+ * somebody's source data. The bundled runner keeps the first 500 lines at
179
+ * 2,000 characters each and appends a line saying how many it dropped — a
180
+ * truncation nobody is told about is the same failure as a log nobody is told
181
+ * about. Consumers cap again for display, more tightly.
182
+ */
162
183
  logs: string[];
163
184
  elapsedMs: number;
164
185
  }
@@ -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,9 +46,41 @@ 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>;
65
+ /**
66
+ * Discard every tier-0 edit at once.
67
+ *
68
+ * **An implementation that really discards must emit `overlay.reset`**, and
69
+ * the reason is an asymmetry this class would otherwise have: the two patches
70
+ * above are audited one field at a time, so a trail could say who renamed one
71
+ * column and not who reverted every name in the catalog — while both need only
72
+ * `catalog:curate`. The summary has to be built before the write; nothing
73
+ * versions an overlay, so afterwards there is nothing left to read.
74
+ *
75
+ * **Refusing is an implementation too, and refusing emits nothing.** A
76
+ * registry whose curated values have no derived layer underneath them has
77
+ * nothing to fall back to, so a reset there is destruction rather than a
78
+ * revert, and the throw is the whole answer — no act, no record of one.
79
+ * `StoredCatalogRegistry` is that case.
80
+ *
81
+ * Which of those a deployment runs is why the event is worth more than the
82
+ * call it accompanies: a registry that quietly resets without emitting looks
83
+ * exactly like one that never ran a reset at all.
84
+ */
20
85
  abstract resetOverlay(): Promise<void>;
21
86
  }
@@ -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,11 +31,21 @@ 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. */
38
37
  patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string]): Promise<CatalogObjectTypeDef | undefined>;
38
+ /**
39
+ * Drop every tier-0 edit, and leave a record that it happened.
40
+ *
41
+ * The summary is taken before the overlay is cleared because it is the only
42
+ * record there will ever be: nothing versions an overlay, so the discarded
43
+ * values are gone the instant the store is written. See `overlay.reset` in
44
+ * `catalog.events.ts` for why the payload is a summary and not a copy.
45
+ *
46
+ * Emitted after the write, like the two patches above, so the trail says what
47
+ * happened rather than what was about to.
48
+ */
39
49
  resetOverlay(): Promise<void>;
40
50
  private persist;
41
51
  private rebuild;