@dudousxd/nestjs-catalog 0.5.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/README.md +163 -0
- package/dist/access.controller.js +25 -0
- package/dist/catalog.controller.d.ts +46 -0
- package/dist/catalog.controller.js +199 -23
- package/dist/catalog.decorators.d.ts +17 -0
- package/dist/catalog.decorators.js +17 -0
- package/dist/catalog.environment.d.ts +104 -12
- package/dist/catalog.environment.js +64 -1
- package/dist/catalog.events.d.ts +67 -3
- package/dist/catalog.events.js +15 -2
- package/dist/catalog.pipeline.d.ts +19 -0
- package/dist/catalog.principal.d.ts +49 -1
- package/dist/catalog.principal.js +92 -0
- package/dist/catalog.registry.js +117 -28
- package/dist/catalog.service.d.ts +53 -10
- package/dist/catalog.service.js +180 -15
- package/dist/catalog.types.d.ts +114 -3
- package/dist/catalog.workspace.d.ts +68 -8
- package/dist/catalog.workspace.js +23 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -2
- package/package.json +1 -1
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
|
*
|
|
@@ -3,7 +3,7 @@ import { type CatalogQueryRelation, type CatalogQueryResult } from './catalog.qu
|
|
|
3
3
|
import { CatalogRegistry } from './catalog.registry.base';
|
|
4
4
|
import { type CatalogReadStore, type SnapshotRef } from './catalog.store';
|
|
5
5
|
import type { CatalogGraph, CatalogObjectPage, CatalogObjectQuery, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
|
|
6
|
-
import { type AuditQuery, type CatalogAuditEvent, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, type EmbeddedChart, type EmbeddedDashboard, type SaveQueryInput, type SavedQuery } from './catalog.workspace';
|
|
6
|
+
import { type AuditQuery, type CatalogAuditEvent, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, type EmbeddedChart, type EmbeddedChartPlacement, type EmbeddedDashboard, type SaveQueryInput, type SavedQuery } from './catalog.workspace';
|
|
7
7
|
/**
|
|
8
8
|
* Reads objects of any catalogued type through one endpoint.
|
|
9
9
|
*
|
|
@@ -32,6 +32,17 @@ export declare class CatalogService {
|
|
|
32
32
|
resetOverlay(): Promise<void>;
|
|
33
33
|
/** Columns a generic UI may render: visible, and not a blob. */
|
|
34
34
|
visibleColumns(type: CatalogObjectTypeDef): import("./catalog.types").CatalogPropertyDef[];
|
|
35
|
+
/**
|
|
36
|
+
* Rows of one type, paged.
|
|
37
|
+
*
|
|
38
|
+
* **No principal, and so no access control.** This applies the guardrails that
|
|
39
|
+
* hold for every caller — the type exists, the page is bounded, the sort names
|
|
40
|
+
* a real column — and none that depend on who is asking: a classified column
|
|
41
|
+
* comes back to whoever the host's guard let through the door. That is the
|
|
42
|
+
* library's declare-and-enforce split, written out at length above `mayWrite`
|
|
43
|
+
* in `catalog.principal.ts`. A host that wants per-principal reads passes this
|
|
44
|
+
* page through `readableObjectPage`.
|
|
45
|
+
*/
|
|
35
46
|
readObjects(typeName: string, query: CatalogObjectQuery & {
|
|
36
47
|
snapshot?: string;
|
|
37
48
|
}): Promise<CatalogObjectPage>;
|
|
@@ -68,9 +79,22 @@ export declare class CatalogService {
|
|
|
68
79
|
private requireWorkspace;
|
|
69
80
|
listSavedQueries(): Promise<SavedQuery[]>;
|
|
70
81
|
getSavedQuery(id: string): Promise<SavedQuery>;
|
|
82
|
+
/**
|
|
83
|
+
* @param createdBy who saved it — the row's author and the audit entry's
|
|
84
|
+
* actor. The host's resolved principal id where the host resolves one; see
|
|
85
|
+
* the enforcement note in `catalog.principal.ts` for why this library cannot
|
|
86
|
+
* work it out itself.
|
|
87
|
+
*/
|
|
71
88
|
saveQuery(input: SaveQueryInput, createdBy: string): Promise<SavedQuery>;
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
/** @param changedBy who made the change, for the audit trail. */
|
|
90
|
+
updateSavedQuery(id: string, input: Partial<SaveQueryInput>, changedBy: string): Promise<SavedQuery>;
|
|
91
|
+
/**
|
|
92
|
+
* @param deletedBy who deleted it, for the audit trail. Required rather than
|
|
93
|
+
* defaulted, matching `saveQuery` and `updateSavedQuery`: a default would
|
|
94
|
+
* quietly attribute revocations to nobody in every caller that was not
|
|
95
|
+
* updated, and the trail's whole value here is that it names somebody.
|
|
96
|
+
*/
|
|
97
|
+
deleteSavedQuery(id: string, deletedBy: string): Promise<boolean>;
|
|
74
98
|
/** Runs a saved query, honouring the TTL it was saved with. */
|
|
75
99
|
runSavedQuery(id: string, maxRows?: number): Promise<{
|
|
76
100
|
savedQuery: SavedQuery;
|
|
@@ -78,17 +102,31 @@ export declare class CatalogService {
|
|
|
78
102
|
}>;
|
|
79
103
|
listDashboards(): Promise<Dashboard[]>;
|
|
80
104
|
getDashboard(id: string): Promise<Dashboard>;
|
|
105
|
+
/**
|
|
106
|
+
* `shared` is declared here, and that is not cosmetic.
|
|
107
|
+
*
|
|
108
|
+
* The store has always accepted it, so it worked as long as the body reached
|
|
109
|
+
* the store untouched. A host with a whitelisting `ValidationPipe` — the
|
|
110
|
+
* normal, recommended configuration — strips a property no type declares, and
|
|
111
|
+
* the symptom is a dashboard that cannot be shared with no error anywhere:
|
|
112
|
+
* the toggle saves, the response says `shared: false`, and the embed API
|
|
113
|
+
* keeps answering 403 for a board somebody just shared.
|
|
114
|
+
*/
|
|
81
115
|
saveDashboard(input: {
|
|
82
116
|
name: string;
|
|
83
117
|
description?: string;
|
|
84
118
|
cards?: DashboardCard[];
|
|
119
|
+
shared?: boolean;
|
|
85
120
|
}, createdBy: string): Promise<Dashboard>;
|
|
121
|
+
/** @param changedBy who made the change, for the audit trail. */
|
|
86
122
|
updateDashboard(id: string, input: Partial<{
|
|
87
123
|
name: string;
|
|
88
124
|
description: string;
|
|
89
125
|
cards: DashboardCard[];
|
|
90
|
-
|
|
91
|
-
|
|
126
|
+
shared: boolean;
|
|
127
|
+
}>, changedBy: string): Promise<Dashboard>;
|
|
128
|
+
/** @param deletedBy who deleted it. See {@link deleteSavedQuery}. */
|
|
129
|
+
deleteDashboard(id: string, deletedBy: string): Promise<boolean>;
|
|
92
130
|
/** Everything shared, so a consumer can discover what it may render. */
|
|
93
131
|
listEmbeddable(): Promise<{
|
|
94
132
|
dashboards: Array<{
|
|
@@ -104,11 +142,16 @@ export declare class CatalogService {
|
|
|
104
142
|
kind: string;
|
|
105
143
|
}>;
|
|
106
144
|
}>;
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
145
|
+
/**
|
|
146
|
+
* One chart, rendered.
|
|
147
|
+
*
|
|
148
|
+
* `placement` is what the dashboard card said, and it is honoured rather than
|
|
149
|
+
* merely carried: a card's `title` and `library` exist to override the saved
|
|
150
|
+
* query on THIS board, so an embed that ignored them would show a different
|
|
151
|
+
* heading and a different chart from the console for the same dashboard —
|
|
152
|
+
* silently, with nothing thrown and nothing logged.
|
|
153
|
+
*/
|
|
154
|
+
embedChart(savedQueryId: string, placement?: EmbeddedChartPlacement): Promise<EmbeddedChart>;
|
|
112
155
|
/** A whole dashboard, every chart resolved. */
|
|
113
156
|
embedDashboard(dashboardId: string): Promise<EmbeddedDashboard>;
|
|
114
157
|
listEvents(query: AuditQuery): Promise<CatalogAuditEvent[]>;
|
package/dist/catalog.service.js
CHANGED
|
@@ -14,6 +14,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.CatalogService = void 0;
|
|
16
16
|
const common_1 = require("@nestjs/common");
|
|
17
|
+
const catalog_events_1 = require("./catalog.events");
|
|
17
18
|
const catalog_options_1 = require("./catalog.options");
|
|
18
19
|
const catalog_query_1 = require("./catalog.query");
|
|
19
20
|
const catalog_query_cache_1 = require("./catalog.query-cache");
|
|
@@ -79,6 +80,17 @@ let CatalogService = class CatalogService {
|
|
|
79
80
|
visibleColumns(type) {
|
|
80
81
|
return type.properties.filter((p) => !p.hidden && p.type !== 'json');
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Rows of one type, paged.
|
|
85
|
+
*
|
|
86
|
+
* **No principal, and so no access control.** This applies the guardrails that
|
|
87
|
+
* hold for every caller — the type exists, the page is bounded, the sort names
|
|
88
|
+
* a real column — and none that depend on who is asking: a classified column
|
|
89
|
+
* comes back to whoever the host's guard let through the door. That is the
|
|
90
|
+
* library's declare-and-enforce split, written out at length above `mayWrite`
|
|
91
|
+
* in `catalog.principal.ts`. A host that wants per-principal reads passes this
|
|
92
|
+
* page through `readableObjectPage`.
|
|
93
|
+
*/
|
|
82
94
|
async readObjects(typeName, query) {
|
|
83
95
|
const type = this.registry.getType(typeName);
|
|
84
96
|
if (!type)
|
|
@@ -213,23 +225,120 @@ let CatalogService = class CatalogService {
|
|
|
213
225
|
throw new common_1.NotFoundException(`No saved query ${id}`);
|
|
214
226
|
return found;
|
|
215
227
|
}
|
|
216
|
-
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Sharing is audited, and the six methods below are where.
|
|
230
|
+
//
|
|
231
|
+
// `shared` on a saved query or a dashboard is the entire embed boundary: it is
|
|
232
|
+
// the one field that hands another company's frontend rows out of this
|
|
233
|
+
// catalog, and it is set by a person clicking a toggle. So it is emitted the
|
|
234
|
+
// way every other governance decision here is — `type.curated`,
|
|
235
|
+
// `transform.changed` — rather than being the one that is not.
|
|
236
|
+
//
|
|
237
|
+
// Two rules hold across all six, and both are load-bearing:
|
|
238
|
+
//
|
|
239
|
+
// *On the transition, never on the write.* A save that leaves the flag where
|
|
240
|
+
// it was is not a sharing decision. A trail that recorded one per keystroke
|
|
241
|
+
// would be a trail people learn to scroll past, which costs more than the
|
|
242
|
+
// entries are worth. Un-sharing is emitted too, under the same event name with
|
|
243
|
+
// `shared: false` — a trail that records only grants cannot answer "was this
|
|
244
|
+
// still shared last Tuesday".
|
|
245
|
+
//
|
|
246
|
+
// *Against what the store returned, never against what the caller asked for.*
|
|
247
|
+
// A store that ignores `shared` must not produce an entry claiming access was
|
|
248
|
+
// granted when nothing was.
|
|
249
|
+
//
|
|
250
|
+
// **Deleting is a transition.** For a while the first rule was applied only to
|
|
251
|
+
// the writes, so revoking access with the delete button — which is how it
|
|
252
|
+
// actually gets revoked — left nothing at all, and the only way to date the
|
|
253
|
+
// revocation was to notice that a thing had stopped appearing. Deleting
|
|
254
|
+
// something shared now emits `shared: false` with `deleted: true`, under the
|
|
255
|
+
// same event name, so the one filter anybody runs answers the whole question.
|
|
256
|
+
//
|
|
257
|
+
// Deleting something *un*shared emits nothing, and that is the first rule
|
|
258
|
+
// rather than an exception to it: an unshared query was not reachable from
|
|
259
|
+
// outside before and is not reachable after, so no access changed. Recording
|
|
260
|
+
// it would put entries carrying no grant and no revocation on the one channel
|
|
261
|
+
// whose entries all carry one. A host that wants every deletion in the trail
|
|
262
|
+
// wants a workspace-lifecycle event, which is a different event and not this
|
|
263
|
+
// one.
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
/**
|
|
266
|
+
* @param createdBy who saved it — the row's author and the audit entry's
|
|
267
|
+
* actor. The host's resolved principal id where the host resolves one; see
|
|
268
|
+
* the enforcement note in `catalog.principal.ts` for why this library cannot
|
|
269
|
+
* work it out itself.
|
|
270
|
+
*/
|
|
271
|
+
async saveQuery(input, createdBy) {
|
|
217
272
|
if (!input?.name?.trim()) {
|
|
218
273
|
throw new common_1.BadRequestException('A saved query needs a name.');
|
|
219
274
|
}
|
|
220
275
|
(0, catalog_query_1.assertReadOnlyShape)(input.sql ?? '');
|
|
221
|
-
|
|
276
|
+
const saved = await this.requireWorkspace().saveQuery(input, createdBy);
|
|
277
|
+
// Born shared is a grant with nothing to transition from: an outside
|
|
278
|
+
// application can fetch it the moment this returns. Born unshared is not an
|
|
279
|
+
// event at all.
|
|
280
|
+
if (saved.shared) {
|
|
281
|
+
(0, catalog_events_1.emitCatalog)('query.shared', {
|
|
282
|
+
savedQueryId: saved.id,
|
|
283
|
+
name: saved.name,
|
|
284
|
+
shared: true,
|
|
285
|
+
principalId: createdBy,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return saved;
|
|
222
289
|
}
|
|
223
|
-
|
|
290
|
+
/** @param changedBy who made the change, for the audit trail. */
|
|
291
|
+
async updateSavedQuery(id, input, changedBy) {
|
|
224
292
|
if (input.sql !== undefined)
|
|
225
293
|
(0, catalog_query_1.assertReadOnlyShape)(input.sql);
|
|
294
|
+
// Read the old value only when the flag is in play. A transition needs both
|
|
295
|
+
// ends, and every other edit — a rename, a new chart type — should not pay
|
|
296
|
+
// for a round trip it does not need.
|
|
297
|
+
const before = input.shared === undefined ? undefined : await this.requireWorkspace().getSavedQuery(id);
|
|
226
298
|
const updated = await this.requireWorkspace().updateSavedQuery(id, input);
|
|
227
299
|
if (!updated)
|
|
228
300
|
throw new common_1.NotFoundException(`No saved query ${id}`);
|
|
301
|
+
// `before` missing while the update succeeded means a store that disagrees
|
|
302
|
+
// with itself about whether this query exists. The transition is then
|
|
303
|
+
// unknowable, and an audit trail should over-record a grant rather than
|
|
304
|
+
// miss one, so it is emitted.
|
|
305
|
+
if (input.shared !== undefined && before?.shared !== updated.shared) {
|
|
306
|
+
(0, catalog_events_1.emitCatalog)('query.shared', {
|
|
307
|
+
savedQueryId: updated.id,
|
|
308
|
+
name: updated.name,
|
|
309
|
+
shared: updated.shared,
|
|
310
|
+
principalId: changedBy,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
229
313
|
return updated;
|
|
230
314
|
}
|
|
231
|
-
|
|
232
|
-
|
|
315
|
+
/**
|
|
316
|
+
* @param deletedBy who deleted it, for the audit trail. Required rather than
|
|
317
|
+
* defaulted, matching `saveQuery` and `updateSavedQuery`: a default would
|
|
318
|
+
* quietly attribute revocations to nobody in every caller that was not
|
|
319
|
+
* updated, and the trail's whole value here is that it names somebody.
|
|
320
|
+
*/
|
|
321
|
+
async deleteSavedQuery(id, deletedBy) {
|
|
322
|
+
// Read unconditionally, unlike `updateSavedQuery` which reads only when the
|
|
323
|
+
// flag is in play. A delete carries no statement of intent about `shared`,
|
|
324
|
+
// so there is nothing to branch on — whether this revokes access is a
|
|
325
|
+
// property of the row, and the row is about to stop existing.
|
|
326
|
+
const before = await this.requireWorkspace().getSavedQuery(id);
|
|
327
|
+
const deleted = await this.requireWorkspace().deleteSavedQuery(id);
|
|
328
|
+
// Only when the store says it went, and only when it was reachable from
|
|
329
|
+
// outside beforehand. A delete that removed nothing revoked nothing, and an
|
|
330
|
+
// unshared query's deletion is not an access event.
|
|
331
|
+
if (deleted && before?.shared) {
|
|
332
|
+
(0, catalog_events_1.emitCatalog)('query.shared', {
|
|
333
|
+
savedQueryId: before.id,
|
|
334
|
+
// The name as it last read. Nothing can look it up after this.
|
|
335
|
+
name: before.name,
|
|
336
|
+
shared: false,
|
|
337
|
+
principalId: deletedBy,
|
|
338
|
+
deleted: true,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return deleted;
|
|
233
342
|
}
|
|
234
343
|
/** Runs a saved query, honouring the TTL it was saved with. */
|
|
235
344
|
async runSavedQuery(id, maxRows) {
|
|
@@ -250,20 +359,61 @@ let CatalogService = class CatalogService {
|
|
|
250
359
|
throw new common_1.NotFoundException(`No dashboard ${id}`);
|
|
251
360
|
return found;
|
|
252
361
|
}
|
|
253
|
-
|
|
362
|
+
/**
|
|
363
|
+
* `shared` is declared here, and that is not cosmetic.
|
|
364
|
+
*
|
|
365
|
+
* The store has always accepted it, so it worked as long as the body reached
|
|
366
|
+
* the store untouched. A host with a whitelisting `ValidationPipe` — the
|
|
367
|
+
* normal, recommended configuration — strips a property no type declares, and
|
|
368
|
+
* the symptom is a dashboard that cannot be shared with no error anywhere:
|
|
369
|
+
* the toggle saves, the response says `shared: false`, and the embed API
|
|
370
|
+
* keeps answering 403 for a board somebody just shared.
|
|
371
|
+
*/
|
|
372
|
+
async saveDashboard(input, createdBy) {
|
|
254
373
|
if (!input?.name?.trim()) {
|
|
255
374
|
throw new common_1.BadRequestException('A dashboard needs a name.');
|
|
256
375
|
}
|
|
257
|
-
|
|
376
|
+
const saved = await this.requireWorkspace().saveDashboard(input, createdBy);
|
|
377
|
+
if (saved.shared) {
|
|
378
|
+
(0, catalog_events_1.emitCatalog)('dashboard.shared', {
|
|
379
|
+
dashboardId: saved.id,
|
|
380
|
+
name: saved.name,
|
|
381
|
+
shared: true,
|
|
382
|
+
principalId: createdBy,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return saved;
|
|
258
386
|
}
|
|
259
|
-
|
|
387
|
+
/** @param changedBy who made the change, for the audit trail. */
|
|
388
|
+
async updateDashboard(id, input, changedBy) {
|
|
389
|
+
const before = input.shared === undefined ? undefined : await this.requireWorkspace().getDashboard(id);
|
|
260
390
|
const updated = await this.requireWorkspace().updateDashboard(id, input);
|
|
261
391
|
if (!updated)
|
|
262
392
|
throw new common_1.NotFoundException(`No dashboard ${id}`);
|
|
393
|
+
if (input.shared !== undefined && before?.shared !== updated.shared) {
|
|
394
|
+
(0, catalog_events_1.emitCatalog)('dashboard.shared', {
|
|
395
|
+
dashboardId: updated.id,
|
|
396
|
+
name: updated.name,
|
|
397
|
+
shared: updated.shared,
|
|
398
|
+
principalId: changedBy,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
263
401
|
return updated;
|
|
264
402
|
}
|
|
265
|
-
|
|
266
|
-
|
|
403
|
+
/** @param deletedBy who deleted it. See {@link deleteSavedQuery}. */
|
|
404
|
+
async deleteDashboard(id, deletedBy) {
|
|
405
|
+
const before = await this.requireWorkspace().getDashboard(id);
|
|
406
|
+
const deleted = await this.requireWorkspace().deleteDashboard(id);
|
|
407
|
+
if (deleted && before?.shared) {
|
|
408
|
+
(0, catalog_events_1.emitCatalog)('dashboard.shared', {
|
|
409
|
+
dashboardId: before.id,
|
|
410
|
+
name: before.name,
|
|
411
|
+
shared: false,
|
|
412
|
+
principalId: deletedBy,
|
|
413
|
+
deleted: true,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return deleted;
|
|
267
417
|
}
|
|
268
418
|
// ---------------------------------------------------------------------------
|
|
269
419
|
// Embed: what another application's frontend gets.
|
|
@@ -298,8 +448,16 @@ let CatalogService = class CatalogService {
|
|
|
298
448
|
})),
|
|
299
449
|
};
|
|
300
450
|
}
|
|
301
|
-
/**
|
|
302
|
-
|
|
451
|
+
/**
|
|
452
|
+
* One chart, rendered.
|
|
453
|
+
*
|
|
454
|
+
* `placement` is what the dashboard card said, and it is honoured rather than
|
|
455
|
+
* merely carried: a card's `title` and `library` exist to override the saved
|
|
456
|
+
* query on THIS board, so an embed that ignored them would show a different
|
|
457
|
+
* heading and a different chart from the console for the same dashboard —
|
|
458
|
+
* silently, with nothing thrown and nothing logged.
|
|
459
|
+
*/
|
|
460
|
+
async embedChart(savedQueryId, placement) {
|
|
303
461
|
const saved = await this.getSavedQuery(savedQueryId);
|
|
304
462
|
if (!saved.shared) {
|
|
305
463
|
throw new common_1.ForbiddenException(`"${saved.name}" has not been shared. Mark it shared in the console to make it embeddable.`);
|
|
@@ -308,12 +466,15 @@ let CatalogService = class CatalogService {
|
|
|
308
466
|
sql: saved.sql,
|
|
309
467
|
cacheTtlSeconds: saved.cacheTtlSeconds,
|
|
310
468
|
});
|
|
469
|
+
// A card whose title was cleared falls back to the query's name rather than
|
|
470
|
+
// embedding a blank heading — an empty override is the absence of one.
|
|
471
|
+
const overridden = placement?.title?.trim();
|
|
311
472
|
return {
|
|
312
473
|
id: saved.id,
|
|
313
|
-
title: saved.name,
|
|
474
|
+
title: overridden ? overridden : saved.name,
|
|
314
475
|
description: saved.description,
|
|
315
|
-
visualization: saved.visualization,
|
|
316
|
-
layout,
|
|
476
|
+
visualization: (0, catalog_workspace_1.embeddedVisualization)(saved.visualization, placement?.library),
|
|
477
|
+
layout: placement ? { width: placement.width, position: placement.position } : undefined,
|
|
317
478
|
columns: result.columns,
|
|
318
479
|
rows: result.rows,
|
|
319
480
|
rowCount: result.rowCount,
|
|
@@ -336,6 +497,10 @@ let CatalogService = class CatalogService {
|
|
|
336
497
|
charts.push(await this.embedChart(card.savedQueryId, {
|
|
337
498
|
width: card.width,
|
|
338
499
|
position: card.position,
|
|
500
|
+
// Everything the card says about this chart, not only where it
|
|
501
|
+
// sits. See `EmbeddedChartPlacement`.
|
|
502
|
+
...(card.title !== undefined ? { title: card.title } : {}),
|
|
503
|
+
...(card.library !== undefined ? { library: card.library } : {}),
|
|
339
504
|
}));
|
|
340
505
|
}
|
|
341
506
|
catch {
|