@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.
- package/dist/catalog.controller.d.ts +6 -0
- package/dist/catalog.controller.js +54 -0
- package/dist/catalog.events.d.ts +72 -1
- package/dist/catalog.events.js +6 -0
- package/dist/catalog.overlay-store.d.ts +22 -1
- package/dist/catalog.overlay-store.js +47 -6
- package/dist/catalog.pipeline.d.ts +22 -1
- package/dist/catalog.registry.base.d.ts +66 -1
- package/dist/catalog.registry.base.js +102 -0
- package/dist/catalog.registry.d.ts +12 -2
- package/dist/catalog.registry.js +39 -68
- package/dist/catalog.service.d.ts +17 -0
- package/dist/catalog.service.js +72 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -2
- package/dist/search.d.ts +184 -0
- package/dist/search.js +345 -0
- package/dist/search.types.d.ts +93 -0
- package/dist/search.types.js +17 -0
- package/dist/transform-runner.js +223 -10
- package/package.json +1 -1
|
@@ -42,6 +42,12 @@ import { type Type } from '@nestjs/common';
|
|
|
42
42
|
* every label one request at a time. Requiring admin would deny nothing and
|
|
43
43
|
* would push a routine console action into the scope that manages principals.
|
|
44
44
|
*
|
|
45
|
+
* **`GET search` is the one read here that looks at the principal.** Its scope
|
|
46
|
+
* is the unsurprising `catalog:read`; what is surprising is that it filters, in
|
|
47
|
+
* a library whose read routes deliberately do not. The reason it has to is on
|
|
48
|
+
* the route itself, and it is short: a host can wrap a read whose subject it
|
|
49
|
+
* knows, and cannot wrap one whose result set is chosen by a stranger's typing.
|
|
50
|
+
*
|
|
45
51
|
* One consequence is worth stating rather than leaving to be discovered:
|
|
46
52
|
* `shared` rides on the dashboard write routes, so `catalog:curate` carries the
|
|
47
53
|
* power to hand a board to an outside application. That is not an oversight —
|
|
@@ -62,6 +62,12 @@ const catalog_workspace_1 = require("./catalog.workspace");
|
|
|
62
62
|
* every label one request at a time. Requiring admin would deny nothing and
|
|
63
63
|
* would push a routine console action into the scope that manages principals.
|
|
64
64
|
*
|
|
65
|
+
* **`GET search` is the one read here that looks at the principal.** Its scope
|
|
66
|
+
* is the unsurprising `catalog:read`; what is surprising is that it filters, in
|
|
67
|
+
* a library whose read routes deliberately do not. The reason it has to is on
|
|
68
|
+
* the route itself, and it is short: a host can wrap a read whose subject it
|
|
69
|
+
* knows, and cannot wrap one whose result set is chosen by a stranger's typing.
|
|
70
|
+
*
|
|
65
71
|
* One consequence is worth stating rather than leaving to be discovered:
|
|
66
72
|
* `shared` rides on the dashboard write routes, so `catalog:curate` carries the
|
|
67
73
|
* power to hand a board to an outside application. That is not an oversight —
|
|
@@ -95,6 +101,44 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
95
101
|
graph() {
|
|
96
102
|
return this.registry.getGraph();
|
|
97
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* One term, across object types, properties, saved queries and dashboards.
|
|
106
|
+
*
|
|
107
|
+
* `catalog:read`, which is the scope of the four routes this is a shortcut
|
|
108
|
+
* to — `GET /catalog`, `GET saved-queries`, `GET dashboards` — and
|
|
109
|
+
* deliberately not narrower. A discovery route gated harder than the things
|
|
110
|
+
* it discovers is a route nobody can use; one gated softer is a way around
|
|
111
|
+
* them. Nothing here reaches a row of data or a statement: it returns names,
|
|
112
|
+
* labels and ids, which is a strict subset of what `GET /catalog` already
|
|
113
|
+
* hands the same caller.
|
|
114
|
+
*
|
|
115
|
+
* **The principal is passed, and this is one of two routes on this
|
|
116
|
+
* controller that filter by it.** The library's usual position is that reads
|
|
117
|
+
* apply no grants and a host wraps them — stated at length above `mayWrite`
|
|
118
|
+
* in `catalog.principal.ts`, and true of `GET objects/:name` right below.
|
|
119
|
+
* That position does not survive contact with a search box. A host can wrap
|
|
120
|
+
* `readObjects` because it knows the type being read and can decide; it
|
|
121
|
+
* cannot wrap this, because the whole result set is chosen by what somebody
|
|
122
|
+
* typed and the thing that must not come back is a NAME, which no wrapper
|
|
123
|
+
* downstream can distinguish from a name that was fine. Filtering has to
|
|
124
|
+
* happen where the candidates are enumerated, so it happens in
|
|
125
|
+
* `CatalogService.search`, and `visibleToPrincipal` is where to read the
|
|
126
|
+
* rules.
|
|
127
|
+
*
|
|
128
|
+
* The `@Req()` here is the same shape `actorOf` reads below, and for the
|
|
129
|
+
* same reason: a guard the host wrote put it there, and when no guard did,
|
|
130
|
+
* `undefined` arrives and nothing is filtered — see the service.
|
|
131
|
+
*
|
|
132
|
+
* Declared before every route carrying a parameter in the first segment,
|
|
133
|
+
* which today is none of them, so this is defensive rather than load-bearing.
|
|
134
|
+
* `events/traces` further down is the case where it is not.
|
|
135
|
+
*/
|
|
136
|
+
search(q, limit, request) {
|
|
137
|
+
return this.service.search(q ?? '', {
|
|
138
|
+
principal: request?.principal,
|
|
139
|
+
limit: limit ? Number(limit) : undefined,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
98
142
|
type(name) {
|
|
99
143
|
const type = this.registry.getType(name);
|
|
100
144
|
if (!type)
|
|
@@ -347,6 +391,16 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
347
391
|
__metadata("design:paramtypes", []),
|
|
348
392
|
__metadata("design:returntype", void 0)
|
|
349
393
|
], CatalogController.prototype, "graph", null);
|
|
394
|
+
__decorate([
|
|
395
|
+
(0, common_1.Get)('search'),
|
|
396
|
+
(0, catalog_route_auth_1.RequireScopes)('catalog:read'),
|
|
397
|
+
__param(0, (0, common_1.Query)('q')),
|
|
398
|
+
__param(1, (0, common_1.Query)('limit')),
|
|
399
|
+
__param(2, (0, common_1.Req)()),
|
|
400
|
+
__metadata("design:type", Function),
|
|
401
|
+
__metadata("design:paramtypes", [String, String, Object]),
|
|
402
|
+
__metadata("design:returntype", void 0)
|
|
403
|
+
], CatalogController.prototype, "search", null);
|
|
350
404
|
__decorate([
|
|
351
405
|
(0, common_1.Get)('types/:name'),
|
|
352
406
|
(0, catalog_route_auth_1.RequireScopes)('catalog:read'),
|
package/dist/catalog.events.d.ts
CHANGED
|
@@ -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;
|
package/dist/catalog.events.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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;
|