@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.
- package/dist/catalog.decorators.d.ts +17 -0
- package/dist/catalog.decorators.js +17 -0
- package/dist/catalog.environment.d.ts +39 -0
- package/dist/catalog.environment.js +50 -0
- package/dist/catalog.pipeline.d.ts +19 -0
- package/dist/catalog.registry.js +117 -28
- package/dist/catalog.types.d.ts +107 -2
- package/package.json +1 -1
|
@@ -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
|
/**
|
package/dist/catalog.registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
*
|
package/dist/catalog.types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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",
|