@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.
@@ -27,6 +27,50 @@ const RELATION_KINDS = ['1:1', '1:m', 'm:1', 'm:n'];
27
27
  function isRelationKind(kind) {
28
28
  return RELATION_KINDS.includes(kind);
29
29
  }
30
+ /**
31
+ * Which end of the link holds the key.
32
+ *
33
+ * Not simply `prop.owner`, and the difference matters. MikroORM sets that flag
34
+ * while resolving the *pair*, so it is dependable for `1:1` and `m:n` — where
35
+ * either side could plausibly own the key and only the mapping says which — and
36
+ * beside the point for the two kinds that have no choice: a `m:1` is the many
37
+ * end and therefore always holds the column, a `1:m` is the one end and
38
+ * therefore never does. Deriving those two from the kind rather than from a flag
39
+ * also means metadata assembled by hand (an `EntitySchema`, or a test) answers
40
+ * correctly without having to know the flag exists.
41
+ *
42
+ * `mappedBy` is checked first because it is unambiguous wherever it appears: the
43
+ * ORM only ever writes it on the inverse side.
44
+ */
45
+ function isOwningSide(prop, kind) {
46
+ if (prop.mappedBy)
47
+ return false;
48
+ if (kind === '1:m')
49
+ return false;
50
+ if (kind === 'm:1')
51
+ return true;
52
+ return Boolean(prop.owner);
53
+ }
54
+ /**
55
+ * A key both ends of one link agree on, so the graph can draw it once.
56
+ *
57
+ * The owning end names the link — `Mvr.base` — and the inverse end, which knows
58
+ * the owner's property through `mappedBy`, arrives at the same string. That is
59
+ * the whole trick, and it is why `inverseName` is carried on the def at all.
60
+ *
61
+ * The fallback covers metadata that names neither end of the pair: both rows
62
+ * then reduce to the unordered pair plus the property name, which collapses the
63
+ * symmetric case (two `m:n` sides spelled alike) and leaves genuinely different
64
+ * names as two links. Guessing harder than that would mean pairing links by
65
+ * shape, and drawing one line where the schema has two is the worse error.
66
+ */
67
+ function linkKey(holder, relation) {
68
+ if (relation.owner)
69
+ return `${holder}.${relation.name}`;
70
+ if (relation.inverseName)
71
+ return `${relation.targetType}.${relation.inverseName}`;
72
+ return `${[holder, relation.targetType].sort().join('::')}::${relation.name}`;
73
+ }
30
74
  /** Classify one type name. Returns "unknown" when nothing matches. */
31
75
  function classify(raw) {
32
76
  const t = raw.toLowerCase();
@@ -156,7 +200,6 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
156
200
  }
157
201
  getGraph() {
158
202
  const snapshot = this.getSnapshot();
159
- const known = new Set(snapshot.types.map((t) => t.name));
160
203
  const nodes = snapshot.types.map((t) => ({
161
204
  id: t.name,
162
205
  label: t.displayName,
@@ -165,29 +208,7 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
165
208
  propertyCount: t.properties.length,
166
209
  relationCount: t.relations.length,
167
210
  }));
168
- // Both ends of a relation are declared, so every link shows up twice. Keep
169
- // one edge per unordered pair per name so the graph does not double up.
170
- const seen = new Set();
171
- const edges = [];
172
- for (const type of snapshot.types) {
173
- for (const relation of type.relations) {
174
- if (!known.has(relation.targetType))
175
- continue;
176
- const pair = [type.name, relation.targetType].sort().join('::');
177
- const key = `${pair}::${relation.name}`;
178
- if (seen.has(key))
179
- continue;
180
- seen.add(key);
181
- edges.push({
182
- id: `${type.name}.${relation.name}`,
183
- source: type.name,
184
- target: relation.targetType,
185
- label: relation.displayName,
186
- kind: relation.kind,
187
- });
188
- }
189
- }
190
- return { nodes, edges };
211
+ return { nodes, edges: buildEdges(snapshot.types) };
191
212
  }
192
213
  /** Tier-0 edit on a type. Never touches the database. */
193
214
  async patchType(typeName, patch) {
@@ -245,13 +266,21 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
245
266
  // here yields an empty array and a catalog that silently contains nothing.
246
267
  const all = this.orm.getMetadata().getAll();
247
268
  const types = [];
269
+ // Two passes, because a relation cannot be described without knowing the
270
+ // whole catalog: whether its target is published is a fact about the
271
+ // catalog, not about the entity being read, and a single pass would answer
272
+ // it differently depending on discovery order.
248
273
  this.entityClasses.clear();
274
+ const included = [];
249
275
  for (const meta of all.values()) {
250
276
  if (!this.shouldInclude(meta))
251
277
  continue;
252
278
  this.entityClasses.set(meta.className, meta.class);
253
- types.push(this.buildType(meta));
279
+ included.push(meta);
254
280
  }
281
+ const published = new Set(included.map((meta) => meta.className));
282
+ for (const meta of included)
283
+ types.push(this.buildType(meta, published));
255
284
  types.sort((a, b) => a.group.localeCompare(b.group) || a.displayName.localeCompare(b.displayName));
256
285
  this.version += 1;
257
286
  this.snapshot = {
@@ -279,7 +308,7 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
279
308
  return false;
280
309
  return true;
281
310
  }
282
- buildType(meta) {
311
+ buildType(meta, published) {
283
312
  const entityClass = meta.class;
284
313
  const declared = (0, catalog_decorators_1.readTypeOptions)(entityClass);
285
314
  const declaredProps = (0, catalog_decorators_1.readPropertyOptions)(entityClass);
@@ -298,16 +327,28 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
298
327
  const fromOverlay = overlayProps[prop.name];
299
328
  const { displayName, description, hidden, order } = resolveFieldPresentation(prop.name, index, fromDecorator, fromOverlay);
300
329
  if (isRelationKind(prop.kind)) {
330
+ const targetType = prop.targetMeta?.className ?? String(prop.type);
301
331
  relations.push({
302
332
  name: prop.name,
303
333
  displayName,
304
334
  description,
305
335
  kind: prop.kind,
306
- targetType: prop.targetMeta?.className ?? String(prop.type),
336
+ targetType,
307
337
  localKey: prop.fieldNames?.[0],
308
338
  nullable: Boolean(prop.nullable),
309
339
  hidden,
310
340
  order,
341
+ owner: isOwningSide(prop, prop.kind),
342
+ // Either name identifies the same thing — the property at the other
343
+ // end — and only one of them is ever set, on the side the ORM decided
344
+ // is inverse. Read as one field because callers pairing the two ends
345
+ // do not care which of the two spellings carried it.
346
+ inverseName: prop.mappedBy || prop.inversedBy || undefined,
347
+ targetPublished: published.has(targetType),
348
+ // A relation is enriched on the same terms as a scalar: somebody said
349
+ // something about it. Structure is not enrichment — every relation
350
+ // here was derived, so its existence proves nothing about curation.
351
+ enriched: Boolean(fromDecorator || fromOverlay),
311
352
  });
312
353
  return;
313
354
  }
@@ -341,7 +382,12 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
341
382
  primaryKey: meta.primaryKeys ?? [],
342
383
  enriched: Object.keys(declared).length > 0 ||
343
384
  Object.keys(overlay).length > 0 ||
344
- properties.some((p) => p.enriched),
385
+ properties.some((p) => p.enriched) ||
386
+ // Relations count too. A type whose only human input is "this link is
387
+ // called Home base" has been worked on, and leaving it out of the tally
388
+ // put it back on the "nobody has named this" list the curator uses to
389
+ // decide what to do next.
390
+ relations.some((r) => r.enriched),
345
391
  properties,
346
392
  relations,
347
393
  };
@@ -354,6 +400,49 @@ exports.MikroOrmCatalogRegistry = MikroOrmCatalogRegistry = MikroOrmCatalogRegis
354
400
  __param(2, (0, common_1.Inject)(catalog_overlay_store_token_1.CATALOG_OVERLAY_STORE)),
355
401
  __metadata("design:paramtypes", [core_1.MikroORM, Object, Object])
356
402
  ], MikroOrmCatalogRegistry);
403
+ /**
404
+ * The links, as lines to draw.
405
+ *
406
+ * Two rules, and both exist because the naive version of this drew a picture
407
+ * that was wrong in a way nobody would notice:
408
+ *
409
+ * 1. **One edge per link.** A link declared at both ends produces two rows, and
410
+ * keying the de-duplication on the property name only caught the case where
411
+ * both ends happened to be spelled alike — so `Mvr.base` plus `Base.mvrs`,
412
+ * the ordinary shape, drew two lines between the same pair of nodes. See
413
+ * {@link linkKey}.
414
+ * 2. **Drawn from the end that holds the key**, so the arrow points the way a
415
+ * join is written. Both ends are collected before either is chosen, because
416
+ * otherwise the direction depends on which type was discovered first.
417
+ *
418
+ * Hidden relations are deliberately still drawn. Hiding is a statement about a
419
+ * table cell; a graph that quietly dropped edges would be a picture nobody could
420
+ * read as complete, which is the only thing a graph is for.
421
+ */
422
+ function buildEdges(types) {
423
+ const byLink = new Map();
424
+ for (const type of types) {
425
+ for (const relation of type.relations) {
426
+ if (!relation.targetPublished)
427
+ continue;
428
+ const key = linkKey(type.name, relation);
429
+ const seen = byLink.get(key);
430
+ // Replace only when this row is the owning one and the row already held
431
+ // is not: anything else keeps the first, so the edge order stays the type
432
+ // order rather than shuffling with every rebuild.
433
+ if (seen && (seen.relation.owner || !relation.owner))
434
+ continue;
435
+ byLink.set(key, { holder: type.name, relation });
436
+ }
437
+ }
438
+ return [...byLink.values()].map(({ holder, relation }) => ({
439
+ id: `${holder}.${relation.name}`,
440
+ source: holder,
441
+ target: relation.targetType,
442
+ label: relation.displayName,
443
+ kind: relation.kind,
444
+ }));
445
+ }
357
446
  /**
358
447
  * How one field is presented, resolved across the tiers.
359
448
  *
@@ -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
- updateSavedQuery(id: string, input: Partial<SaveQueryInput>): Promise<SavedQuery>;
73
- deleteSavedQuery(id: string): Promise<boolean>;
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
- }>): Promise<Dashboard>;
91
- deleteDashboard(id: string): Promise<boolean>;
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
- /** One chart, rendered. */
108
- embedChart(savedQueryId: string, layout?: {
109
- width: number;
110
- position: number;
111
- }): Promise<EmbeddedChart>;
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[]>;
@@ -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
- saveQuery(input, createdBy) {
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
- return this.requireWorkspace().saveQuery(input, createdBy);
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
- async updateSavedQuery(id, input) {
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
- deleteSavedQuery(id) {
232
- return this.requireWorkspace().deleteSavedQuery(id);
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
- saveDashboard(input, createdBy) {
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
- return this.requireWorkspace().saveDashboard(input, createdBy);
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
- async updateDashboard(id, input) {
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
- deleteDashboard(id) {
266
- return this.requireWorkspace().deleteDashboard(id);
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
- /** One chart, rendered. */
302
- async embedChart(savedQueryId, layout) {
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 {