@dudousxd/nestjs-catalog 0.6.0 → 0.7.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.
@@ -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
  /**
@@ -27,6 +27,50 @@ 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
+ }
54
+ /**
55
+ * A key both ends of one link agree on, so the graph can draw it once.
56
+ *
57
+ * The owning end names the link — `Mvr.base` — and the inverse end, which knows
58
+ * the owner's property through `mappedBy`, arrives at the same string. That is
59
+ * the whole trick, and it is why `inverseName` is carried on the def at all.
60
+ *
61
+ * The fallback covers metadata that names neither end of the pair: both rows
62
+ * then reduce to the unordered pair plus the property name, which collapses the
63
+ * symmetric case (two `m:n` sides spelled alike) and leaves genuinely different
64
+ * names as two links. Guessing harder than that would mean pairing links by
65
+ * shape, and drawing one line where the schema has two is the worse error.
66
+ */
67
+ function linkKey(holder, relation) {
68
+ if (relation.owner)
69
+ return `${holder}.${relation.name}`;
70
+ if (relation.inverseName)
71
+ return `${relation.targetType}.${relation.inverseName}`;
72
+ return `${[holder, relation.targetType].sort().join('::')}::${relation.name}`;
73
+ }
30
74
  /** Classify one type name. Returns "unknown" when nothing matches. */
31
75
  function classify(raw) {
32
76
  const t = raw.toLowerCase();
@@ -156,7 +200,6 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
156
200
  }
157
201
  getGraph() {
158
202
  const snapshot = this.getSnapshot();
159
- const known = new Set(snapshot.types.map((t) => t.name));
160
203
  const nodes = snapshot.types.map((t) => ({
161
204
  id: t.name,
162
205
  label: t.displayName,
@@ -165,29 +208,7 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
165
208
  propertyCount: t.properties.length,
166
209
  relationCount: t.relations.length,
167
210
  }));
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 };
211
+ return { nodes, edges: buildEdges(snapshot.types) };
191
212
  }
192
213
  /** Tier-0 edit on a type. Never touches the database. */
193
214
  async patchType(typeName, patch) {
@@ -245,13 +266,21 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
245
266
  // here yields an empty array and a catalog that silently contains nothing.
246
267
  const all = this.orm.getMetadata().getAll();
247
268
  const types = [];
269
+ // Two passes, because a relation cannot be described without knowing the
270
+ // whole catalog: whether its target is published is a fact about the
271
+ // catalog, not about the entity being read, and a single pass would answer
272
+ // it differently depending on discovery order.
248
273
  this.entityClasses.clear();
274
+ const included = [];
249
275
  for (const meta of all.values()) {
250
276
  if (!this.shouldInclude(meta))
251
277
  continue;
252
278
  this.entityClasses.set(meta.className, meta.class);
253
- types.push(this.buildType(meta));
279
+ included.push(meta);
254
280
  }
281
+ const published = new Set(included.map((meta) => meta.className));
282
+ for (const meta of included)
283
+ types.push(this.buildType(meta, published));
255
284
  types.sort((a, b) => a.group.localeCompare(b.group) || a.displayName.localeCompare(b.displayName));
256
285
  this.version += 1;
257
286
  this.snapshot = {
@@ -279,7 +308,7 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
279
308
  return false;
280
309
  return true;
281
310
  }
282
- buildType(meta) {
311
+ buildType(meta, published) {
283
312
  const entityClass = meta.class;
284
313
  const declared = (0, catalog_decorators_1.readTypeOptions)(entityClass);
285
314
  const declaredProps = (0, catalog_decorators_1.readPropertyOptions)(entityClass);
@@ -298,16 +327,28 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
298
327
  const fromOverlay = overlayProps[prop.name];
299
328
  const { displayName, description, hidden, order } = resolveFieldPresentation(prop.name, index, fromDecorator, fromOverlay);
300
329
  if (isRelationKind(prop.kind)) {
330
+ const targetType = prop.targetMeta?.className ?? String(prop.type);
301
331
  relations.push({
302
332
  name: prop.name,
303
333
  displayName,
304
334
  description,
305
335
  kind: prop.kind,
306
- targetType: prop.targetMeta?.className ?? String(prop.type),
336
+ targetType,
307
337
  localKey: prop.fieldNames?.[0],
308
338
  nullable: Boolean(prop.nullable),
309
339
  hidden,
310
340
  order,
341
+ owner: isOwningSide(prop, prop.kind),
342
+ // Either name identifies the same thing — the property at the other
343
+ // end — and only one of them is ever set, on the side the ORM decided
344
+ // is inverse. Read as one field because callers pairing the two ends
345
+ // do not care which of the two spellings carried it.
346
+ inverseName: prop.mappedBy || prop.inversedBy || undefined,
347
+ targetPublished: published.has(targetType),
348
+ // A relation is enriched on the same terms as a scalar: somebody said
349
+ // something about it. Structure is not enrichment — every relation
350
+ // here was derived, so its existence proves nothing about curation.
351
+ enriched: Boolean(fromDecorator || fromOverlay),
311
352
  });
312
353
  return;
313
354
  }
@@ -341,7 +382,12 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
341
382
  primaryKey: meta.primaryKeys ?? [],
342
383
  enriched: Object.keys(declared).length > 0 ||
343
384
  Object.keys(overlay).length > 0 ||
344
- properties.some((p) => p.enriched),
385
+ properties.some((p) => p.enriched) ||
386
+ // Relations count too. A type whose only human input is "this link is
387
+ // called Home base" has been worked on, and leaving it out of the tally
388
+ // put it back on the "nobody has named this" list the curator uses to
389
+ // decide what to do next.
390
+ relations.some((r) => r.enriched),
345
391
  properties,
346
392
  relations,
347
393
  };
@@ -354,6 +400,49 @@ exports.MikroOrmCatalogRegistry = MikroOrmCatalogRegistry = MikroOrmCatalogRegis
354
400
  __param(2, (0, common_1.Inject)(catalog_overlay_store_token_1.CATALOG_OVERLAY_STORE)),
355
401
  __metadata("design:paramtypes", [core_1.MikroORM, Object, Object])
356
402
  ], MikroOrmCatalogRegistry);
403
+ /**
404
+ * The links, as lines to draw.
405
+ *
406
+ * Two rules, and both exist because the naive version of this drew a picture
407
+ * that was wrong in a way nobody would notice:
408
+ *
409
+ * 1. **One edge per link.** A link declared at both ends produces two rows, and
410
+ * keying the de-duplication on the property name only caught the case where
411
+ * both ends happened to be spelled alike — so `Mvr.base` plus `Base.mvrs`,
412
+ * the ordinary shape, drew two lines between the same pair of nodes. See
413
+ * {@link linkKey}.
414
+ * 2. **Drawn from the end that holds the key**, so the arrow points the way a
415
+ * join is written. Both ends are collected before either is chosen, because
416
+ * otherwise the direction depends on which type was discovered first.
417
+ *
418
+ * Hidden relations are deliberately still drawn. Hiding is a statement about a
419
+ * table cell; a graph that quietly dropped edges would be a picture nobody could
420
+ * read as complete, which is the only thing a graph is for.
421
+ */
422
+ function buildEdges(types) {
423
+ const byLink = new Map();
424
+ for (const type of types) {
425
+ for (const relation of type.relations) {
426
+ if (!relation.targetPublished)
427
+ continue;
428
+ const key = linkKey(type.name, relation);
429
+ const seen = byLink.get(key);
430
+ // Replace only when this row is the owning one and the row already held
431
+ // is not: anything else keeps the first, so the edge order stays the type
432
+ // order rather than shuffling with every rebuild.
433
+ if (seen && (seen.relation.owner || !relation.owner))
434
+ continue;
435
+ byLink.set(key, { holder: type.name, relation });
436
+ }
437
+ }
438
+ return [...byLink.values()].map(({ holder, relation }) => ({
439
+ id: `${holder}.${relation.name}`,
440
+ source: holder,
441
+ target: relation.targetType,
442
+ label: relation.displayName,
443
+ kind: relation.kind,
444
+ }));
445
+ }
357
446
  /**
358
447
  * How one field is presented, resolved across the tiers.
359
448
  *
@@ -54,7 +54,24 @@ export interface CatalogPropertyDef {
54
54
  /** True when the value came from a hand-written decorator rather than the ORM. */
55
55
  enriched: boolean;
56
56
  }
57
- /** A link between two object types. Derived entirely from the ORM. */
57
+ /**
58
+ * A link between two object types — the thing that makes this an ontology
59
+ * rather than a list of tables.
60
+ *
61
+ * **Structure derived, semantics declared**, the same split as everywhere else.
62
+ * A `@ManyToOne` already names its target, its kind and its join column, so none
63
+ * of that is ever written by hand — a decorator that could restate it is a
64
+ * decorator that can disagree with the schema. What a human adds is what they
65
+ * add to a scalar, a label and a meaning, through `@CatalogProperty` or the
66
+ * overlay; both key on the property name and so reach a relation without having
67
+ * to know it is one.
68
+ *
69
+ * **One row per declaration, not per link.** `@ManyToOne(() => Base)` on `Mvr`
70
+ * with the matching `@OneToMany` on `Base` is two rows describing one link.
71
+ * Collapsing them here would mean `Base` could not carry its own label for the
72
+ * end it declares, and a type could not say what it points at without consulting
73
+ * every other type. The graph collapses them instead — see {@link CatalogGraph}.
74
+ */
58
75
  export interface CatalogRelationDef {
59
76
  name: string;
60
77
  displayName: string;
@@ -67,6 +84,39 @@ export interface CatalogRelationDef {
67
84
  nullable: boolean;
68
85
  hidden: boolean;
69
86
  order: number;
87
+ /**
88
+ * True when this side physically holds the key.
89
+ *
90
+ * The two ends of a link are not interchangeable. The owning end is where the
91
+ * foreign key actually is, so it is the end a join is written from, the end
92
+ * whose column can be indexed, and the end whose removal breaks the link. A
93
+ * `1:m` is never the owner — the key lives on the many side.
94
+ */
95
+ owner: boolean;
96
+ /**
97
+ * The property on {@link targetType} that is the other end of this same link,
98
+ * when the ORM knows it (MikroORM's `mappedBy` / `inversedBy`).
99
+ *
100
+ * This is what lets two rows be recognised as one link. Pairing them by name
101
+ * instead only works for the accident of both ends being spelled the same:
102
+ * `Mvr.base` and `Base.mvrs` are one link and would otherwise draw two edges,
103
+ * which is exactly the picture a graph is supposed to prevent.
104
+ */
105
+ inverseName?: string;
106
+ /**
107
+ * Whether {@link targetType} is a type this catalog actually holds.
108
+ *
109
+ * False when the target was excluded by configuration, or belongs to an
110
+ * application that has not published it. The relation is still reported: that
111
+ * an MVR points at something called `Base` is true, and when the other end is
112
+ * missing that is the most useful single fact about it. Dropping it silently
113
+ * would leave a type looking unlinked when it is really linked to something
114
+ * out of reach — but drawing it as a navigable edge promises a node that
115
+ * cannot be opened, so the graph omits it and the type page keeps it, marked.
116
+ */
117
+ targetPublished: boolean;
118
+ /** True when a human has labelled or described this link. */
119
+ enriched: boolean;
70
120
  }
71
121
  /** One node of the ontology. */
72
122
  export interface CatalogObjectTypeDef {
@@ -97,6 +147,40 @@ export interface CatalogObjectTypeDef {
97
147
  * came from a regex and are probably wrong.
98
148
  */
99
149
  enriched: boolean;
150
+ /**
151
+ * When readers started seeing the data this type currently serves.
152
+ *
153
+ * `committedAt` of the newest committed snapshot, not `createdAt`: a load that
154
+ * was written and never committed is not what anybody is reading, and dating
155
+ * the type by it would report freshness nobody has.
156
+ *
157
+ * **Absent means no committed snapshot ever**, which is a different statement
158
+ * from "committed a year ago" — a type published by a schema and never loaded
159
+ * looked exactly like a type loaded daily until this field existed, and both
160
+ * looked exactly like a type whose publisher was deleted six months ago.
161
+ *
162
+ * Carried on the type rather than fetched per type on demand. The value of
163
+ * this signal is that it arrives without anybody going to look for it, and a
164
+ * field costing one request per row on a screen listing every type is a field
165
+ * that screen will not use.
166
+ */
167
+ lastCommittedAt?: string;
168
+ /**
169
+ * Rows in that snapshot.
170
+ *
171
+ * Here for a failure the timestamp cannot show: a connector that starts
172
+ * returning 12 rows where it returned 40,000 produces data that is wrong and
173
+ * *fresh*, so every staleness signal reports it as healthy. The count next to
174
+ * the date is what makes that visible, and it is the same read.
175
+ */
176
+ rowCount?: number;
177
+ /**
178
+ * Which application committed it.
179
+ *
180
+ * Because "stale since March" is never the last question — "so who was
181
+ * loading this?" is — and the answer is already in the row being read.
182
+ */
183
+ lastPrincipalId?: string;
100
184
  properties: CatalogPropertyDef[];
101
185
  relations: CatalogRelationDef[];
102
186
  }
@@ -108,12 +192,27 @@ export interface CatalogSnapshot {
108
192
  stats: {
109
193
  types: number;
110
194
  properties: number;
195
+ /**
196
+ * Declared relations, summed over the types — **not** distinct links. A link
197
+ * declared at both ends counts twice, because that is what this number is
198
+ * derived from and quietly halving it would make it disagree with the rows
199
+ * on the type pages that produce it. `getGraph().edges.length` is the count
200
+ * of links.
201
+ */
111
202
  relations: number;
112
203
  enrichedTypes: number;
113
204
  };
114
205
  types: CatalogObjectTypeDef[];
115
206
  }
116
- /** Nodes and edges, for drawing the ontology. */
207
+ /**
208
+ * Nodes and edges, for drawing the ontology.
209
+ *
210
+ * One edge per **link**, not per declaration: a link declared at both ends is
211
+ * one line on the picture, drawn from the end that holds the key so the arrow
212
+ * points the way a join is written. And every edge lands on a node that is
213
+ * present — a target this catalog does not hold produces no edge, because an
214
+ * edge to nowhere is a node the reader will try to click.
215
+ */
117
216
  export interface CatalogGraph {
118
217
  nodes: Array<{
119
218
  id: string;
@@ -143,6 +242,12 @@ export interface CatalogOverlay {
143
242
  icon?: string;
144
243
  group?: string;
145
244
  titleProperty?: string;
245
+ /**
246
+ * Keyed by property name — and a relation is a property to whoever is
247
+ * looking, so a link's label and description are curated through this map
248
+ * too, under the relation's own name. That is why curating a link needs no
249
+ * new route and no new patch shape: `patchProperty` already accepts one.
250
+ */
146
251
  properties?: Record<string, {
147
252
  displayName?: string;
148
253
  description?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-catalog",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A metadata registry for NestJS: object types, properties and relations, derived from your ORM and enriched with decorators.",
5
5
  "license": "MIT",
6
6
  "author": "Davide Carvalho",