@dudousxd/nestjs-catalog 0.7.0 → 0.8.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.
@@ -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'),
@@ -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,7 +46,19 @@ 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
- abstract getGraph(): CatalogGraph;
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>;
@@ -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 { CatalogGraph, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
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,7 +31,6 @@ 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. */
@@ -51,26 +51,6 @@ function isOwningSide(prop, kind) {
51
51
  return true;
52
52
  return Boolean(prop.owner);
53
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
- }
74
54
  /** Classify one type name. Returns "unknown" when nothing matches. */
75
55
  function classify(raw) {
76
56
  const t = raw.toLowerCase();
@@ -198,18 +178,6 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
198
178
  this.rebuild();
199
179
  return this.entityClasses.get(name);
200
180
  }
201
- getGraph() {
202
- const snapshot = this.getSnapshot();
203
- const nodes = snapshot.types.map((t) => ({
204
- id: t.name,
205
- label: t.displayName,
206
- group: t.group,
207
- icon: t.icon,
208
- propertyCount: t.properties.length,
209
- relationCount: t.relations.length,
210
- }));
211
- return { nodes, edges: buildEdges(snapshot.types) };
212
- }
213
181
  /** Tier-0 edit on a type. Never touches the database. */
214
182
  async patchType(typeName, patch) {
215
183
  const type = this.getType(typeName);
@@ -400,49 +368,6 @@ exports.MikroOrmCatalogRegistry = MikroOrmCatalogRegistry = MikroOrmCatalogRegis
400
368
  __param(2, (0, common_1.Inject)(catalog_overlay_store_token_1.CATALOG_OVERLAY_STORE)),
401
369
  __metadata("design:paramtypes", [core_1.MikroORM, Object, Object])
402
370
  ], 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
- }
446
371
  /**
447
372
  * How one field is presented, resolved across the tiers.
448
373
  *
@@ -1,9 +1,11 @@
1
1
  import { type CatalogModuleOptions } from './catalog.options';
2
+ import type { CatalogPrincipal } from './catalog.principal';
2
3
  import { type CatalogQueryRelation, type CatalogQueryResult } from './catalog.query';
3
4
  import { CatalogRegistry } from './catalog.registry.base';
4
5
  import { type CatalogReadStore, type SnapshotRef } from './catalog.store';
5
6
  import type { CatalogGraph, CatalogObjectPage, CatalogObjectQuery, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
6
7
  import { type AuditQuery, type CatalogAuditEvent, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, type EmbeddedChart, type EmbeddedChartPlacement, type EmbeddedDashboard, type SaveQueryInput, type SavedQuery } from './catalog.workspace';
8
+ import type { CatalogSearchResult } from './search.types';
7
9
  /**
8
10
  * Reads objects of any catalogued type through one endpoint.
9
11
  *
@@ -155,4 +157,19 @@ export declare class CatalogService {
155
157
  /** A whole dashboard, every chart resolved. */
156
158
  embedDashboard(dashboardId: string): Promise<EmbeddedDashboard>;
157
159
  listEvents(query: AuditQuery): Promise<CatalogAuditEvent[]>;
160
+ /**
161
+ * Everything matching `term` that this principal may see.
162
+ *
163
+ * @param principal the caller, when the host resolved one. **Optional, and its
164
+ * absence filters nothing** — the declare-and-enforce split written out above
165
+ * `mayWrite` in `catalog.principal.ts` means this library never resolves a
166
+ * principal itself. In a deployment with no guard, `GET /catalog` already
167
+ * hands over the whole snapshot, so search is exactly as open as what is
168
+ * already there and strictly narrower the moment a principal appears. See
169
+ * {@link visibleToPrincipal}.
170
+ */
171
+ search(term: string, options?: {
172
+ principal?: CatalogPrincipal;
173
+ limit?: number;
174
+ }): Promise<CatalogSearchResult>;
158
175
  }
@@ -21,6 +21,7 @@ const catalog_query_cache_1 = require("./catalog.query-cache");
21
21
  const catalog_registry_base_1 = require("./catalog.registry.base");
22
22
  const catalog_store_1 = require("./catalog.store");
23
23
  const catalog_workspace_1 = require("./catalog.workspace");
24
+ const search_1 = require("./search");
24
25
  const DEFAULT_PAGE_SIZE = 25;
25
26
  const DEFAULT_MAX_PAGE_SIZE = 200;
26
27
  /**
@@ -519,6 +520,77 @@ let CatalogService = class CatalogService {
519
520
  listEvents(query) {
520
521
  return this.workspace ? this.workspace.listEvents(query) : Promise.resolve([]);
521
522
  }
523
+ // ---------------------------------------------------------------------------
524
+ // Search: one term, four kinds of thing.
525
+ //
526
+ // **One call that fans out, rather than four the client merges**, and the
527
+ // reason is not the round trips.
528
+ //
529
+ // Half of this is already free: the registry snapshot is in memory, so every
530
+ // type and every property costs a loop over an object this process is holding
531
+ // anyway. Only the workspace half touches a store, and it does so as one
532
+ // `Promise.all` — so the wall clock is the slower of two reads, not four
533
+ // sequential fetches from a browser. A client that split this to render the
534
+ // free half a few milliseconds earlier would be buying that with a second
535
+ // request and a second cache key.
536
+ //
537
+ // What actually decides it is that a merged list needs ONE ranking. Four
538
+ // routes means the client owns the ordering across kinds, which means the
539
+ // ordering lives in the browser, which means every other consumer of this HTTP
540
+ // API — and there is meant to be one, that is what `client.ts` is for —
541
+ // reinvents it slightly differently. And the access filter would have four
542
+ // places to be forgotten instead of one, which for the thing that decides
543
+ // whether a caller learns the name of a type they cannot read is not a
544
+ // trade worth making for a progress spinner.
545
+ //
546
+ // The cost, stated because it is real: a deployment whose workspace store is
547
+ // slow makes the free half wait for it. If that ever bites, the fix is a
548
+ // `kinds` parameter on this one route, not four routes.
549
+ // ---------------------------------------------------------------------------
550
+ /**
551
+ * Everything matching `term` that this principal may see.
552
+ *
553
+ * @param principal the caller, when the host resolved one. **Optional, and its
554
+ * absence filters nothing** — the declare-and-enforce split written out above
555
+ * `mayWrite` in `catalog.principal.ts` means this library never resolves a
556
+ * principal itself. In a deployment with no guard, `GET /catalog` already
557
+ * hands over the whole snapshot, so search is exactly as open as what is
558
+ * already there and strictly narrower the moment a principal appears. See
559
+ * {@link visibleToPrincipal}.
560
+ */
561
+ async search(term, options = {}) {
562
+ const trimmed = (term ?? '').trim();
563
+ if (!trimmed)
564
+ return (0, search_1.emptySearch)();
565
+ // Answered before either store is touched. A principal that may read
566
+ // nothing should not cost a workspace query to be told so.
567
+ if (!(0, search_1.maySearch)(options.principal))
568
+ return (0, search_1.emptySearch)(trimmed);
569
+ const [savedQueries, dashboards] = await Promise.all([
570
+ this.listSavedQueries(),
571
+ this.listDashboards(),
572
+ ]);
573
+ return (0, search_1.searchCatalog)({
574
+ term: trimmed,
575
+ types: (0, search_1.visibleToPrincipal)(options.principal, this.registry.getSnapshot().types),
576
+ // Narrowed here rather than handed over whole. `searchCatalog` takes the
577
+ // fields it ranks and nothing else, so `sql` cannot reach the matcher even
578
+ // by accident — see `SearchableSavedQuery` for why matching a statement is
579
+ // the wrong feature rather than a missing one.
580
+ savedQueries: savedQueries.map((query) => ({
581
+ id: query.id,
582
+ name: query.name,
583
+ description: query.description,
584
+ folder: query.folder,
585
+ })),
586
+ dashboards: dashboards.map((dashboard) => ({
587
+ id: dashboard.id,
588
+ name: dashboard.name,
589
+ description: dashboard.description,
590
+ })),
591
+ limit: options.limit,
592
+ });
593
+ }
522
594
  };
523
595
  exports.CatalogService = CatalogService;
524
596
  exports.CatalogService = CatalogService = __decorate([
package/dist/client.d.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  export type { AuditQuery, CatalogAuditEvent, Dashboard, DashboardCard, QueryVisualization, SaveQueryInput, SavedQuery, } from './catalog.workspace';
13
13
  export type { CatalogQueryRelation, CatalogQueryRequest, CatalogQueryResult, } from './catalog.query';
14
+ export type { CatalogSearchField, CatalogSearchHit, CatalogSearchKind, CatalogSearchRank, CatalogSearchResult, } from './search.types';
14
15
  export type { CatalogGraph, CatalogObjectPage, CatalogObjectQuery, CatalogObjectTypeDef, CatalogOverlay, CatalogPropertyDef, CatalogRelationDef, CatalogSnapshot, RelationKind, ScalarType, } from './catalog.types';
15
16
  /** What a tier-0 edit to a type may change. */
16
17
  export interface TypePatch {
@@ -45,6 +46,16 @@ export interface ObjectQueryParams {
45
46
  export declare const catalogRoutes: {
46
47
  readonly snapshot: () => string;
47
48
  readonly graph: () => string;
49
+ /**
50
+ * One term across types, properties, saved queries and dashboards.
51
+ *
52
+ * No arguments, unlike `type(name)` and friends: `q` and `limit` are a query
53
+ * string, and every route here that takes one — `objects`, `events`, `traces`
54
+ * — leaves it to the caller's HTTP client, because that is the layer that
55
+ * already knows how to serialise and encode one. `accessRoutes.people` in the
56
+ * React package does it the other way and is the odd one out.
57
+ */
58
+ readonly search: () => string;
48
59
  readonly type: (name: string) => string;
49
60
  readonly property: (name: string, property: string) => string;
50
61
  readonly reset: () => string;
package/dist/client.js CHANGED
@@ -20,6 +20,16 @@ exports.isWorkflowNodeKind = exports.isWorkflowExecutionMode = exports.workflowR
20
20
  exports.catalogRoutes = {
21
21
  snapshot: () => '/catalog',
22
22
  graph: () => '/catalog/graph',
23
+ /**
24
+ * One term across types, properties, saved queries and dashboards.
25
+ *
26
+ * No arguments, unlike `type(name)` and friends: `q` and `limit` are a query
27
+ * string, and every route here that takes one — `objects`, `events`, `traces`
28
+ * — leaves it to the caller's HTTP client, because that is the layer that
29
+ * already knows how to serialise and encode one. `accessRoutes.people` in the
30
+ * React package does it the other way and is the odd one out.
31
+ */
32
+ search: () => '/catalog/search',
23
33
  type: (name) => `/catalog/types/${encodeURIComponent(name)}`,
24
34
  property: (name, property) => `/catalog/types/${encodeURIComponent(name)}/properties/${encodeURIComponent(property)}`,
25
35
  reset: () => '/catalog/reset',
package/dist/index.d.ts CHANGED
@@ -12,6 +12,8 @@ export * from './catalog.environment';
12
12
  export { QueryCache, toCsv } from './catalog.query-cache';
13
13
  export { SubprocessTransformRunner, type TransformRunnerOptions, } from './transform-runner';
14
14
  export { CatalogService } from './catalog.service';
15
+ export { DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT, bestMatch, emptySearch, maySearch, type SearchInput, type SearchableDashboard, type SearchableSavedQuery, searchCatalog, visibleToPrincipal, } from './search';
16
+ export type { CatalogSearchField, CatalogSearchHit, CatalogSearchKind, CatalogSearchRank, CatalogSearchResult, } from './search.types';
15
17
  export { type AuditQuery, CATALOG_TRACE_OUTCOMES, CATALOG_TRACE_STORE, CATALOG_WORKSPACE_STORE, type CatalogAuditEvent, type CatalogTrace, type CatalogTraceList, type CatalogTraceOutcome, type CatalogTraceSpan, type CatalogTraceStore, type CatalogTraceTotals, type CatalogUnlinkedList, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, type EmbeddedChart, type EmbeddedChartPlacement, type EmbeddedDashboard, embeddedVisualization, isCatalogTraceOutcome, isTraceStore, isWorkspaceStore, type QueryVisualization, type SaveQueryInput, type SavedQuery, type TraceQuery, traceOutcomeFilter, } from './catalog.workspace';
16
18
  export { CATALOG_PRINCIPAL_RESOLVER, type CatalogActor, type CatalogGrants, type CatalogPrincipal, type CatalogPrincipalResolver, type CatalogScope, composePrincipalId, delegatePrincipal, expandScopes, hasScope, parsePrincipalId, PRINCIPAL_ACTOR_SEPARATOR, maySeeClassification, mayRead, mayWrite, readableObjectPage, StaticKeyPrincipalResolver, } from './catalog.principal';
17
19
  export * from './catalog.access';
package/dist/index.js CHANGED
@@ -14,8 +14,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.traceOutcomeFilter = exports.isWorkspaceStore = exports.isTraceStore = exports.isCatalogTraceOutcome = exports.embeddedVisualization = exports.CATALOG_WORKSPACE_STORE = exports.CATALOG_TRACE_STORE = exports.CATALOG_TRACE_OUTCOMES = exports.CatalogService = exports.SubprocessTransformRunner = exports.toCsv = exports.QueryCache = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.TRANSFORM_RUNNER = exports.supportsWorkflowStages = exports.supportsWorkflows = exports.isWorkflowNodeKind = exports.isWorkflowNode = exports.isWorkflowExecutionMode = exports.isWorkflowEdge = exports.isTransformLanguage = exports.isPipelineStore = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.CATALOG_PIPELINE_STORE = exports.CatalogRegistry = exports.MikroOrmCatalogRegistry = exports.CATALOG_OVERLAY_STORE = exports.InMemoryCatalogOverlayStore = exports.FileCatalogOverlayStore = exports.CATALOG_OPTIONS = exports.isQueryStore = exports.assertReadOnlyShape = exports.CatalogModule = exports.emitCatalog = exports.channelNameFor = exports.catalogEventPhase = exports.CATALOG_LIB = exports.CATALOG_EVENTS = exports.CATALOG_EVENT_PHASE_FALLBACK = exports.CATALOG_EVENT_PHASE = exports.CatalogType = exports.CatalogProperty = void 0;
18
- exports.RequireScopes = exports.RequireHuman = exports.REQUIRES_HUMAN = exports.REQUIRED_SCOPES = exports.MikroOrmReadStore = exports.supportsCarryForward = exports.isWriteStore = exports.isReservedColumn = exports.isCatalogStoreCapabilities = exports.findColumnCollisions = exports.CatalogColumnCollisionError = exports.CATALOG_STORE = exports.CATALOG_SNAPSHOT_MODES = exports.CATALOG_RESERVED_COLUMNS = exports.assertNoColumnCollisions = exports.StaticKeyPrincipalResolver = exports.readableObjectPage = exports.mayWrite = exports.mayRead = exports.maySeeClassification = exports.PRINCIPAL_ACTOR_SEPARATOR = exports.parsePrincipalId = exports.hasScope = exports.expandScopes = exports.delegatePrincipal = exports.composePrincipalId = exports.CATALOG_PRINCIPAL_RESOLVER = void 0;
17
+ exports.CATALOG_TRACE_OUTCOMES = exports.visibleToPrincipal = exports.searchCatalog = exports.maySearch = exports.emptySearch = exports.bestMatch = exports.MAX_SEARCH_LIMIT = exports.DEFAULT_SEARCH_LIMIT = exports.CatalogService = exports.SubprocessTransformRunner = exports.toCsv = exports.QueryCache = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.TRANSFORM_RUNNER = exports.supportsWorkflowStages = exports.supportsWorkflows = exports.isWorkflowNodeKind = exports.isWorkflowNode = exports.isWorkflowExecutionMode = exports.isWorkflowEdge = exports.isTransformLanguage = exports.isPipelineStore = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.CATALOG_PIPELINE_STORE = exports.CatalogRegistry = exports.MikroOrmCatalogRegistry = exports.CATALOG_OVERLAY_STORE = exports.InMemoryCatalogOverlayStore = exports.FileCatalogOverlayStore = exports.CATALOG_OPTIONS = exports.isQueryStore = exports.assertReadOnlyShape = exports.CatalogModule = exports.emitCatalog = exports.channelNameFor = exports.catalogEventPhase = exports.CATALOG_LIB = exports.CATALOG_EVENTS = exports.CATALOG_EVENT_PHASE_FALLBACK = exports.CATALOG_EVENT_PHASE = exports.CatalogType = exports.CatalogProperty = void 0;
18
+ exports.RequireScopes = exports.RequireHuman = exports.REQUIRES_HUMAN = exports.REQUIRED_SCOPES = exports.MikroOrmReadStore = exports.supportsCarryForward = exports.isWriteStore = exports.isReservedColumn = exports.isCatalogStoreCapabilities = exports.findColumnCollisions = exports.CatalogColumnCollisionError = exports.CATALOG_STORE = exports.CATALOG_SNAPSHOT_MODES = exports.CATALOG_RESERVED_COLUMNS = exports.assertNoColumnCollisions = exports.StaticKeyPrincipalResolver = exports.readableObjectPage = exports.mayWrite = exports.mayRead = exports.maySeeClassification = exports.PRINCIPAL_ACTOR_SEPARATOR = exports.parsePrincipalId = exports.hasScope = exports.expandScopes = exports.delegatePrincipal = exports.composePrincipalId = exports.CATALOG_PRINCIPAL_RESOLVER = exports.traceOutcomeFilter = exports.isWorkspaceStore = exports.isTraceStore = exports.isCatalogTraceOutcome = exports.embeddedVisualization = exports.CATALOG_WORKSPACE_STORE = exports.CATALOG_TRACE_STORE = void 0;
19
19
  var catalog_decorators_1 = require("./catalog.decorators");
20
20
  Object.defineProperty(exports, "CatalogProperty", { enumerable: true, get: function () { return catalog_decorators_1.CatalogProperty; } });
21
21
  Object.defineProperty(exports, "CatalogType", { enumerable: true, get: function () { return catalog_decorators_1.CatalogType; } });
@@ -74,6 +74,21 @@ var transform_runner_1 = require("./transform-runner");
74
74
  Object.defineProperty(exports, "SubprocessTransformRunner", { enumerable: true, get: function () { return transform_runner_1.SubprocessTransformRunner; } });
75
75
  var catalog_service_1 = require("./catalog.service");
76
76
  Object.defineProperty(exports, "CatalogService", { enumerable: true, get: function () { return catalog_service_1.CatalogService; } });
77
+ // Search. The result types are on `/client` too, for a browser; these are here
78
+ // because a host that passed `controller: false` and wrote its own routes needs
79
+ // to type the handler, and — more importantly — needs `visibleToPrincipal` and
80
+ // `maySearch` if it calls `searchCatalog` directly rather than going through
81
+ // `CatalogService.search`. Exporting the matcher without them would ship the
82
+ // half that ranks and withhold the half that decides who may see what, which is
83
+ // the exact shape of the gap `index.barrel.spec.ts` was written after.
84
+ var search_1 = require("./search");
85
+ Object.defineProperty(exports, "DEFAULT_SEARCH_LIMIT", { enumerable: true, get: function () { return search_1.DEFAULT_SEARCH_LIMIT; } });
86
+ Object.defineProperty(exports, "MAX_SEARCH_LIMIT", { enumerable: true, get: function () { return search_1.MAX_SEARCH_LIMIT; } });
87
+ Object.defineProperty(exports, "bestMatch", { enumerable: true, get: function () { return search_1.bestMatch; } });
88
+ Object.defineProperty(exports, "emptySearch", { enumerable: true, get: function () { return search_1.emptySearch; } });
89
+ Object.defineProperty(exports, "maySearch", { enumerable: true, get: function () { return search_1.maySearch; } });
90
+ Object.defineProperty(exports, "searchCatalog", { enumerable: true, get: function () { return search_1.searchCatalog; } });
91
+ Object.defineProperty(exports, "visibleToPrincipal", { enumerable: true, get: function () { return search_1.visibleToPrincipal; } });
77
92
  var catalog_workspace_1 = require("./catalog.workspace");
78
93
  Object.defineProperty(exports, "CATALOG_TRACE_OUTCOMES", { enumerable: true, get: function () { return catalog_workspace_1.CATALOG_TRACE_OUTCOMES; } });
79
94
  Object.defineProperty(exports, "CATALOG_TRACE_STORE", { enumerable: true, get: function () { return catalog_workspace_1.CATALOG_TRACE_STORE; } });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * One box that crosses the catalog.
3
+ *
4
+ * A catalog with two hundred object types is a catalog where finding anything
5
+ * means already knowing which screen it lives on — types and properties on the
6
+ * model screen, saved queries on the query screen, boards on the dashboards
7
+ * screen — and the thing people actually type is a word they half-remember. This
8
+ * module is the half of that which has no request in it: given a term, some
9
+ * types, some saved queries and some dashboards, which rows come back and in
10
+ * what order.
11
+ *
12
+ * Pure on purpose. The ranking is the part a reader has to be able to predict
13
+ * and the part that must not change by accident, so it is a function with no
14
+ * store, no principal and no clock in it, and the two things that DO depend on
15
+ * who is asking — {@link visibleToPrincipal} and the route's scope — sit either
16
+ * side of it where they can be read.
17
+ */
18
+ import { type CatalogPrincipal } from './catalog.principal';
19
+ import type { CatalogObjectTypeDef } from './catalog.types';
20
+ import type { CatalogSearchField, CatalogSearchRank, CatalogSearchResult } from './search.types';
21
+ /**
22
+ * What comes back when nothing was asked for, or when the caller may see
23
+ * nothing at all.
24
+ *
25
+ * A function rather than a shared constant so no two responses can hand out the
26
+ * same `hits` array — a frozen empty list is safe until somebody downstream
27
+ * decides an empty result is a fine thing to push a "nothing found" placeholder
28
+ * onto.
29
+ *
30
+ * The two cases are deliberately indistinguishable from outside. "You may see
31
+ * none of the eleven things that matched" and "eleven things matched, none of
32
+ * them yours" are the same sentence to a caller, and the second one is the
33
+ * disclosure.
34
+ */
35
+ export declare function emptySearch(term?: string): CatalogSearchResult;
36
+ export declare const DEFAULT_SEARCH_LIMIT = 50;
37
+ export declare const MAX_SEARCH_LIMIT = 200;
38
+ interface Candidate {
39
+ field: CatalogSearchField;
40
+ value: string | undefined;
41
+ /**
42
+ * Whether this field says what the thing is CALLED, as opposed to what
43
+ * somebody wrote about it. Identifying fields can reach every rank; describing
44
+ * fields only ever reach `text`.
45
+ */
46
+ identifying: boolean;
47
+ }
48
+ /**
49
+ * The best rank any of these fields can claim for this term, and which field
50
+ * claimed it.
51
+ *
52
+ * Ties go to the field declared first, which is why every call site below lists
53
+ * `name` before `displayName`: on equal evidence the code name wins, because it
54
+ * is the stable identity, the string a URL carries, and the one a person who
55
+ * typed it was almost certainly typing on purpose.
56
+ *
57
+ * `term` is expected already lower-cased and trimmed — done once by the caller
58
+ * rather than per field, since this runs over every property of every type.
59
+ */
60
+ export declare function bestMatch(term: string, candidates: Candidate[]): {
61
+ rank: CatalogSearchRank;
62
+ field: CatalogSearchField;
63
+ } | undefined;
64
+ /**
65
+ * The catalog as this principal is allowed to see it.
66
+ *
67
+ * Two rules, and both are about names rather than values:
68
+ *
69
+ * *A type they may not read does not exist here*, and neither do its properties.
70
+ * A search that answers "there is a type called `PayrollAdjustment`" to somebody
71
+ * whose `readTypes` excludes it has disclosed the thing they were excluded from,
72
+ * even though not one row came back.
73
+ *
74
+ * *A classified property they do not hold the classification for is dropped, not
75
+ * blanked.* `readableObjectPage` deletes such a column from a page of rows for
76
+ * the same reason; here the sensitive part IS the name — `settlement_amount` on
77
+ * a table called `Dispute` is the disclosure, and a hit saying "there is a
78
+ * property here you may not see" is worse than no hit, because it also confirms
79
+ * the guess that produced the search term.
80
+ *
81
+ * **An absent principal filters nothing**, and that is not a fail-open. This
82
+ * library resolves no principal and ships no guard — the split is written out at
83
+ * length above `mayWrite` in `catalog.principal.ts` — so `undefined` here means
84
+ * the host wired no guard, and in that deployment `GET /catalog` already hands
85
+ * the entire snapshot, every type and every property name, to whoever asks.
86
+ * Search must never be a SOFTER path to something than the routes that exist;
87
+ * being exactly as soft as the snapshot route, and strictly harder the moment a
88
+ * principal appears, is the guarantee this can honestly make.
89
+ *
90
+ * Hidden properties are kept. `hidden` is a tier-0 display flag any curator can
91
+ * flip back, it is already in the snapshot, and excluding it would make search
92
+ * the one place a curator cannot find the property they just hid in order to
93
+ * un-hide it.
94
+ */
95
+ export declare function visibleToPrincipal(principal: CatalogPrincipal | undefined, types: CatalogObjectTypeDef[]): CatalogObjectTypeDef[];
96
+ /**
97
+ * Whether this principal may use the search route at all.
98
+ *
99
+ * The route declares `catalog:read` and a host's guard is what enforces it, so
100
+ * in a correctly wired deployment this can never be false. It is asked anyway
101
+ * because of what would otherwise be inconsistent: `visibleToPrincipal` drops
102
+ * every type for a principal without `catalog:read` — `mayRead` checks the scope
103
+ * first — while the saved queries and dashboards, which have no per-object grant
104
+ * to check, would sail through. A route that answers "no types, but here are
105
+ * eleven board names" to somebody who may read nothing is a route whose access
106
+ * story depends on which half of it you read.
107
+ */
108
+ export declare function maySearch(principal: CatalogPrincipal | undefined): boolean;
109
+ /**
110
+ * What a saved query contributes to a search. A subset, not the row: `sql` is
111
+ * deliberately absent from the input as well as the output.
112
+ *
113
+ * Matching on the statement is a tempting feature — "which query touches
114
+ * `mvr`?" — and it is the wrong one here. It turns a name search into a code
115
+ * search, so a term like `select` matches everything; the hit it produces cannot
116
+ * be explained in a row without showing the SQL that justified it; and the row
117
+ * would then be a fragment of a statement rendered somewhere a statement was
118
+ * never meant to appear. A host that wants to grep saved SQL wants a different
119
+ * route with a different name.
120
+ */
121
+ export interface SearchableSavedQuery {
122
+ id: string;
123
+ name: string;
124
+ description?: string;
125
+ /** Free-form grouping. Ranked as a `group`, which is what it is. */
126
+ folder?: string;
127
+ }
128
+ export interface SearchableDashboard {
129
+ id: string;
130
+ name: string;
131
+ description?: string;
132
+ }
133
+ export interface SearchInput {
134
+ term: string;
135
+ types: CatalogObjectTypeDef[];
136
+ savedQueries: SearchableSavedQuery[];
137
+ dashboards: SearchableDashboard[];
138
+ /** Bounded by {@link MAX_SEARCH_LIMIT} whatever is passed. */
139
+ limit?: number;
140
+ }
141
+ /**
142
+ * Search four kinds of thing and return them in one ranked list.
143
+ *
144
+ * ---------------------------------------------------------------------------
145
+ * **Why connectors and transforms are not in here.**
146
+ *
147
+ * Not an oversight, and not something to add later without moving something
148
+ * else first. Connectors and transforms are served by
149
+ * `@dudousxd/nestjs-catalog-pipeline`, a package this one does not depend on and
150
+ * should not: `routes.ts` in the React package makes the argument in full, but
151
+ * the short version is that the catalog library ships no controller for them
152
+ * because how a deployment exposes the code that reshapes its data is the
153
+ * deployment's decision, not this library's.
154
+ *
155
+ * The access consequence is the deciding one. This route declares
156
+ * `catalog:read`. A connector carries a connection reference and a
157
+ * `secretEnvVar` naming where its credential lives, and whatever guard a host
158
+ * put on its pipeline routes, it was not necessarily this one. Folding
159
+ * connectors into a `catalog:read` result would quietly re-grant them under a
160
+ * scope their owner never agreed to — the exact shape of the failure the scope
161
+ * table at the top of `catalog.controller.ts` exists to prevent.
162
+ *
163
+ * So the seam is stated rather than hidden: this searches the registry snapshot
164
+ * plus the workspace store, which are the two things the catalog module owns. A
165
+ * console that wants connectors in the same box makes a second call against the
166
+ * pipeline's own routes, under the pipeline's own guard, and merges two lists —
167
+ * which is honest about the fact that they are two permissions.
168
+ * ---------------------------------------------------------------------------
169
+ *
170
+ * The order, in full, so it can be argued with:
171
+ *
172
+ * 1. rank — `exact`, then `prefix`, then `name`, then `text`;
173
+ * 2. kind — type, property, saved query, dashboard;
174
+ * 3. label, then id, lexicographically.
175
+ *
176
+ * Rank outranks kind because an exact property match is a better answer than a
177
+ * type whose description happens to mention the word. Steps 2 and 3 exist so the
178
+ * result is *total*: a search that returned the same rows in a different order
179
+ * on the next call would make the top of the list flicker under a debounced
180
+ * input, and would make every test of this function a test of `Array.sort`
181
+ * stability.
182
+ */
183
+ export declare function searchCatalog(input: SearchInput): CatalogSearchResult;
184
+ export {};
package/dist/search.js ADDED
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ /**
3
+ * One box that crosses the catalog.
4
+ *
5
+ * A catalog with two hundred object types is a catalog where finding anything
6
+ * means already knowing which screen it lives on — types and properties on the
7
+ * model screen, saved queries on the query screen, boards on the dashboards
8
+ * screen — and the thing people actually type is a word they half-remember. This
9
+ * module is the half of that which has no request in it: given a term, some
10
+ * types, some saved queries and some dashboards, which rows come back and in
11
+ * what order.
12
+ *
13
+ * Pure on purpose. The ranking is the part a reader has to be able to predict
14
+ * and the part that must not change by accident, so it is a function with no
15
+ * store, no principal and no clock in it, and the two things that DO depend on
16
+ * who is asking — {@link visibleToPrincipal} and the route's scope — sit either
17
+ * side of it where they can be read.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.MAX_SEARCH_LIMIT = exports.DEFAULT_SEARCH_LIMIT = void 0;
21
+ exports.emptySearch = emptySearch;
22
+ exports.bestMatch = bestMatch;
23
+ exports.visibleToPrincipal = visibleToPrincipal;
24
+ exports.maySearch = maySearch;
25
+ exports.searchCatalog = searchCatalog;
26
+ const catalog_principal_1 = require("./catalog.principal");
27
+ /**
28
+ * What comes back when nothing was asked for, or when the caller may see
29
+ * nothing at all.
30
+ *
31
+ * A function rather than a shared constant so no two responses can hand out the
32
+ * same `hits` array — a frozen empty list is safe until somebody downstream
33
+ * decides an empty result is a fine thing to push a "nothing found" placeholder
34
+ * onto.
35
+ *
36
+ * The two cases are deliberately indistinguishable from outside. "You may see
37
+ * none of the eleven things that matched" and "eleven things matched, none of
38
+ * them yours" are the same sentence to a caller, and the second one is the
39
+ * disclosure.
40
+ */
41
+ function emptySearch(term = '') {
42
+ return { term, total: 0, truncated: false, hits: [] };
43
+ }
44
+ exports.DEFAULT_SEARCH_LIMIT = 50;
45
+ exports.MAX_SEARCH_LIMIT = 200;
46
+ /**
47
+ * Strongest first. The numbers are only ever compared, never shown — the wire
48
+ * carries the name, so a client can render "exact" without knowing this table.
49
+ */
50
+ const RANK_ORDER = {
51
+ exact: 0,
52
+ prefix: 1,
53
+ name: 2,
54
+ text: 3,
55
+ };
56
+ /**
57
+ * The tie-break after rank, and it is a claim about what people search for.
58
+ *
59
+ * A type is the thing somebody is usually looking for; a property only exists
60
+ * inside one; a saved query and a board are things somebody made later. Equal
61
+ * evidence, so the more likely intent wins. It is stated as a table rather than
62
+ * left to array order because the order results are *built* in is an
63
+ * implementation detail and this is not.
64
+ */
65
+ const KIND_ORDER = {
66
+ objectType: 0,
67
+ property: 1,
68
+ savedQuery: 2,
69
+ dashboard: 3,
70
+ };
71
+ /**
72
+ * The best rank any of these fields can claim for this term, and which field
73
+ * claimed it.
74
+ *
75
+ * Ties go to the field declared first, which is why every call site below lists
76
+ * `name` before `displayName`: on equal evidence the code name wins, because it
77
+ * is the stable identity, the string a URL carries, and the one a person who
78
+ * typed it was almost certainly typing on purpose.
79
+ *
80
+ * `term` is expected already lower-cased and trimmed — done once by the caller
81
+ * rather than per field, since this runs over every property of every type.
82
+ */
83
+ function bestMatch(term, candidates) {
84
+ let best;
85
+ for (const candidate of candidates) {
86
+ const rank = rankOne(term, candidate);
87
+ if (rank && (!best || RANK_ORDER[rank] < RANK_ORDER[best.rank])) {
88
+ best = { rank, field: candidate.field };
89
+ // `exact` is the ceiling, so nothing later can beat it and the remaining
90
+ // fields need not be read at all.
91
+ if (rank === 'exact')
92
+ return best;
93
+ }
94
+ }
95
+ return best;
96
+ }
97
+ /** How well one field matches, on its own. The four tiers, and nothing else. */
98
+ function rankOne(term, candidate) {
99
+ const value = candidate.value?.trim().toLowerCase();
100
+ if (!value)
101
+ return undefined;
102
+ if (!candidate.identifying) {
103
+ // Describing fields get one rank and no gradations. See the note on
104
+ // `CatalogSearchRank`: "the description opens with your word" is not
105
+ // evidence of anything.
106
+ return value.includes(term) ? 'text' : undefined;
107
+ }
108
+ if (value === term)
109
+ return 'exact';
110
+ if (value.startsWith(term))
111
+ return 'prefix';
112
+ if (value.includes(term))
113
+ return 'name';
114
+ return undefined;
115
+ }
116
+ /**
117
+ * The catalog as this principal is allowed to see it.
118
+ *
119
+ * Two rules, and both are about names rather than values:
120
+ *
121
+ * *A type they may not read does not exist here*, and neither do its properties.
122
+ * A search that answers "there is a type called `PayrollAdjustment`" to somebody
123
+ * whose `readTypes` excludes it has disclosed the thing they were excluded from,
124
+ * even though not one row came back.
125
+ *
126
+ * *A classified property they do not hold the classification for is dropped, not
127
+ * blanked.* `readableObjectPage` deletes such a column from a page of rows for
128
+ * the same reason; here the sensitive part IS the name — `settlement_amount` on
129
+ * a table called `Dispute` is the disclosure, and a hit saying "there is a
130
+ * property here you may not see" is worse than no hit, because it also confirms
131
+ * the guess that produced the search term.
132
+ *
133
+ * **An absent principal filters nothing**, and that is not a fail-open. This
134
+ * library resolves no principal and ships no guard — the split is written out at
135
+ * length above `mayWrite` in `catalog.principal.ts` — so `undefined` here means
136
+ * the host wired no guard, and in that deployment `GET /catalog` already hands
137
+ * the entire snapshot, every type and every property name, to whoever asks.
138
+ * Search must never be a SOFTER path to something than the routes that exist;
139
+ * being exactly as soft as the snapshot route, and strictly harder the moment a
140
+ * principal appears, is the guarantee this can honestly make.
141
+ *
142
+ * Hidden properties are kept. `hidden` is a tier-0 display flag any curator can
143
+ * flip back, it is already in the snapshot, and excluding it would make search
144
+ * the one place a curator cannot find the property they just hid in order to
145
+ * un-hide it.
146
+ */
147
+ function visibleToPrincipal(principal, types) {
148
+ if (!principal)
149
+ return types;
150
+ const visible = [];
151
+ for (const type of types) {
152
+ if (!(0, catalog_principal_1.mayRead)(principal, type.name))
153
+ continue;
154
+ const properties = type.properties.filter((property) => (0, catalog_principal_1.maySeeClassification)(principal, property.classification));
155
+ // Rebuilt only when something was actually dropped, so the common case
156
+ // hands back the registry's own object rather than a copy of it per search.
157
+ visible.push(properties.length === type.properties.length ? type : { ...type, properties });
158
+ }
159
+ return visible;
160
+ }
161
+ /**
162
+ * Whether this principal may use the search route at all.
163
+ *
164
+ * The route declares `catalog:read` and a host's guard is what enforces it, so
165
+ * in a correctly wired deployment this can never be false. It is asked anyway
166
+ * because of what would otherwise be inconsistent: `visibleToPrincipal` drops
167
+ * every type for a principal without `catalog:read` — `mayRead` checks the scope
168
+ * first — while the saved queries and dashboards, which have no per-object grant
169
+ * to check, would sail through. A route that answers "no types, but here are
170
+ * eleven board names" to somebody who may read nothing is a route whose access
171
+ * story depends on which half of it you read.
172
+ */
173
+ function maySearch(principal) {
174
+ if (!principal)
175
+ return true;
176
+ return (0, catalog_principal_1.hasScope)(principal, 'catalog:read');
177
+ }
178
+ /**
179
+ * Search four kinds of thing and return them in one ranked list.
180
+ *
181
+ * ---------------------------------------------------------------------------
182
+ * **Why connectors and transforms are not in here.**
183
+ *
184
+ * Not an oversight, and not something to add later without moving something
185
+ * else first. Connectors and transforms are served by
186
+ * `@dudousxd/nestjs-catalog-pipeline`, a package this one does not depend on and
187
+ * should not: `routes.ts` in the React package makes the argument in full, but
188
+ * the short version is that the catalog library ships no controller for them
189
+ * because how a deployment exposes the code that reshapes its data is the
190
+ * deployment's decision, not this library's.
191
+ *
192
+ * The access consequence is the deciding one. This route declares
193
+ * `catalog:read`. A connector carries a connection reference and a
194
+ * `secretEnvVar` naming where its credential lives, and whatever guard a host
195
+ * put on its pipeline routes, it was not necessarily this one. Folding
196
+ * connectors into a `catalog:read` result would quietly re-grant them under a
197
+ * scope their owner never agreed to — the exact shape of the failure the scope
198
+ * table at the top of `catalog.controller.ts` exists to prevent.
199
+ *
200
+ * So the seam is stated rather than hidden: this searches the registry snapshot
201
+ * plus the workspace store, which are the two things the catalog module owns. A
202
+ * console that wants connectors in the same box makes a second call against the
203
+ * pipeline's own routes, under the pipeline's own guard, and merges two lists —
204
+ * which is honest about the fact that they are two permissions.
205
+ * ---------------------------------------------------------------------------
206
+ *
207
+ * The order, in full, so it can be argued with:
208
+ *
209
+ * 1. rank — `exact`, then `prefix`, then `name`, then `text`;
210
+ * 2. kind — type, property, saved query, dashboard;
211
+ * 3. label, then id, lexicographically.
212
+ *
213
+ * Rank outranks kind because an exact property match is a better answer than a
214
+ * type whose description happens to mention the word. Steps 2 and 3 exist so the
215
+ * result is *total*: a search that returned the same rows in a different order
216
+ * on the next call would make the top of the list flicker under a debounced
217
+ * input, and would make every test of this function a test of `Array.sort`
218
+ * stability.
219
+ */
220
+ function searchCatalog(input) {
221
+ const term = input.term.trim().toLowerCase();
222
+ // An empty box is not an error. A search screen mounts empty, and a 400 on
223
+ // mount is a red panel where a prompt should be.
224
+ if (!term)
225
+ return emptySearch();
226
+ const limit = Math.min(Math.max(Math.trunc(Number(input.limit) || exports.DEFAULT_SEARCH_LIMIT), 1), exports.MAX_SEARCH_LIMIT);
227
+ // One flat list, built per kind and ranked as a whole. Building it per kind
228
+ // and concatenating would put the kind order ahead of the rank, which is
229
+ // exactly backwards — see the order stated above.
230
+ const hits = [
231
+ ...input.types.flatMap((type) => hitsForType(term, type)),
232
+ ...input.savedQueries.flatMap((query) => hitsForSavedQuery(term, query)),
233
+ ...input.dashboards.flatMap((dashboard) => hitsForDashboard(term, dashboard)),
234
+ ];
235
+ hits.sort(compareHits);
236
+ return {
237
+ term,
238
+ // Counted before the cut, so a UI can say "50 of 312" — and counted after
239
+ // the caller's own types were filtered out upstream, which is the half that
240
+ // matters. See `CatalogSearchResult.total`.
241
+ total: hits.length,
242
+ truncated: hits.length > limit,
243
+ hits: hits.slice(0, limit),
244
+ };
245
+ }
246
+ /** The type itself, then every property on it. Zero, one or many. */
247
+ function hitsForType(term, type) {
248
+ const hits = [];
249
+ const typeMatch = bestMatch(term, [
250
+ { field: 'name', value: type.name, identifying: true },
251
+ { field: 'displayName', value: type.displayName, identifying: true },
252
+ // The plural is an identifying field too — somebody typing "vehicles" means
253
+ // the type — but it is reported as `displayName`, because "matched:
254
+ // pluralDisplayName" is a distinction no reader of a search row wants.
255
+ { field: 'displayName', value: type.pluralDisplayName, identifying: true },
256
+ { field: 'description', value: type.description, identifying: false },
257
+ { field: 'group', value: type.group, identifying: false },
258
+ ]);
259
+ if (typeMatch) {
260
+ hits.push({
261
+ kind: 'objectType',
262
+ id: type.name,
263
+ label: type.displayName || type.name,
264
+ typeName: type.name,
265
+ detail: type.group || undefined,
266
+ ...typeMatch,
267
+ });
268
+ }
269
+ for (const property of type.properties) {
270
+ const match = bestMatch(term, [
271
+ { field: 'name', value: property.name, identifying: true },
272
+ { field: 'displayName', value: property.displayName, identifying: true },
273
+ { field: 'description', value: property.description, identifying: false },
274
+ { field: 'unit', value: property.unit, identifying: false },
275
+ ]);
276
+ if (!match)
277
+ continue;
278
+ hits.push({
279
+ kind: 'property',
280
+ id: property.name,
281
+ label: property.displayName || property.name,
282
+ typeName: type.name,
283
+ detail: property.unit ? `${property.type} · ${property.unit}` : property.type,
284
+ ...match,
285
+ });
286
+ }
287
+ return hits;
288
+ }
289
+ function hitsForSavedQuery(term, query) {
290
+ const match = bestMatch(term, [
291
+ // A saved query has no code name, so `name` is its one identifying field —
292
+ // listed first, matching the tie-break rule everywhere else.
293
+ { field: 'name', value: query.name, identifying: true },
294
+ { field: 'description', value: query.description, identifying: false },
295
+ { field: 'group', value: query.folder, identifying: false },
296
+ ]);
297
+ if (!match)
298
+ return [];
299
+ return [
300
+ {
301
+ kind: 'savedQuery',
302
+ id: query.id,
303
+ label: query.name,
304
+ detail: query.folder || undefined,
305
+ ...match,
306
+ },
307
+ ];
308
+ }
309
+ function hitsForDashboard(term, dashboard) {
310
+ const match = bestMatch(term, [
311
+ { field: 'name', value: dashboard.name, identifying: true },
312
+ { field: 'description', value: dashboard.description, identifying: false },
313
+ ]);
314
+ if (!match)
315
+ return [];
316
+ return [{ kind: 'dashboard', id: dashboard.id, label: dashboard.name, ...match }];
317
+ }
318
+ function compareHits(a, b) {
319
+ const byRank = RANK_ORDER[a.rank] - RANK_ORDER[b.rank];
320
+ if (byRank !== 0)
321
+ return byRank;
322
+ const byKind = KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
323
+ if (byKind !== 0)
324
+ return byKind;
325
+ // Plain comparison rather than `localeCompare`: the order has to be the same
326
+ // in a test, on a developer's machine and in a container with no ICU data,
327
+ // and a locale-aware collation is the one thing here that differs between all
328
+ // three.
329
+ if (a.label !== b.label)
330
+ return a.label < b.label ? -1 : 1;
331
+ if (a.id !== b.id)
332
+ return a.id < b.id ? -1 : 1;
333
+ // Two properties of the same name on different types. Nothing else can
334
+ // separate them, and leaving it to sort stability would make the order depend
335
+ // on the registry's iteration order.
336
+ //
337
+ // Zero when even that is equal, rather than a constant 1: a comparator that
338
+ // reports `a > b` and `b > a` for the same pair is not an ordering, and some
339
+ // sort implementations are entitled to do anything at all with one.
340
+ const aType = a.typeName ?? '';
341
+ const bType = b.typeName ?? '';
342
+ if (aType === bType)
343
+ return 0;
344
+ return aType < bType ? -1 : 1;
345
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What one search across the catalog gives back.
3
+ *
4
+ * A separate file from `search.ts` so `client.ts` can re-export these without
5
+ * dragging the matcher into a browser bundle. The shapes are the contract — a
6
+ * host writing its own search box needs them as much as it needs the path.
7
+ *
8
+ * **Rows, not objects.** Every hit carries what it takes to draw a line and
9
+ * follow it, and nothing else: no `sql`, no property list, no card layout. That
10
+ * is not a size optimisation. A search route is the one endpoint whose result
11
+ * set is chosen by a stranger's typing, so it is the endpoint where "we returned
12
+ * a bit more than the screen needed" turns into a disclosure nobody reviewed. A
13
+ * caller that wants the whole saved query asks `GET saved-queries/:id`, which is
14
+ * a route somebody thought about.
15
+ */
16
+ /**
17
+ * What kind of thing was found.
18
+ *
19
+ * Four, and the omissions are deliberate — see the block above `searchCatalog`
20
+ * in `search.ts` for why connectors and transforms are not here and cannot be
21
+ * without changing which package owns their access model.
22
+ */
23
+ export type CatalogSearchKind = 'objectType' | 'property' | 'savedQuery' | 'dashboard';
24
+ /**
25
+ * How well it matched, strongest first. Four values rather than a number,
26
+ * because a score is only useful if a reader can predict it, and nobody has ever
27
+ * been able to predict `0.6231`.
28
+ *
29
+ * - `exact` — an identifying field IS the term.
30
+ * - `prefix` — an identifying field starts with it.
31
+ * - `name` — an identifying field contains it somewhere.
32
+ * - `text` — a describing field contains it.
33
+ *
34
+ * Identifying means `name` or `displayName`: what the thing is called. Describing
35
+ * means `description`, `group`, `unit`: what somebody wrote about it. The split
36
+ * is the whole ranking. Within a describing field no distinction is made between
37
+ * "starts with" and "contains", because a description that happens to open with
38
+ * your word is not a better answer than one that mentions it in the middle, and
39
+ * pretending otherwise is where an unpredictable score starts.
40
+ */
41
+ export type CatalogSearchRank = 'exact' | 'prefix' | 'name' | 'text';
42
+ /** Which field the term was found in — the "why" on every row. */
43
+ export type CatalogSearchField = 'name' | 'displayName' | 'description' | 'group' | 'unit';
44
+ export interface CatalogSearchHit {
45
+ kind: CatalogSearchKind;
46
+ /**
47
+ * What identifies it within its kind: the type name, the property name, the
48
+ * saved query's or dashboard's id.
49
+ *
50
+ * Not unique across kinds on its own — a property called `status` on two types
51
+ * is two hits with the same `id` — so anything keying on a hit keys on
52
+ * `kind`, `typeName` and `id` together.
53
+ */
54
+ id: string;
55
+ /** What to show. The display name where there is one, the code name otherwise. */
56
+ label: string;
57
+ /**
58
+ * The object type this result belongs to: itself for an `objectType`, its
59
+ * owner for a `property`, absent for a saved query or a dashboard.
60
+ *
61
+ * Set on the type as well as the property so a client navigating to the model
62
+ * screen writes `hit.typeName && explorerHref(hit.typeName)` once, rather than
63
+ * a branch per kind that will be wrong the first time a kind is added.
64
+ */
65
+ typeName?: string;
66
+ /**
67
+ * One short line of context: the group for a type, the scalar type and unit
68
+ * for a property, the folder for a saved query.
69
+ *
70
+ * Structural, never a snippet of the description. A snippet would have to be
71
+ * cut somewhere, and a description cut mid-sentence is how a classified
72
+ * meaning ends up half-rendered in a dropdown; `field` already says the match
73
+ * was in the description, and the row it links to shows the whole of it.
74
+ */
75
+ detail?: string;
76
+ rank: CatalogSearchRank;
77
+ field: CatalogSearchField;
78
+ }
79
+ export interface CatalogSearchResult {
80
+ /** The term as it was searched, trimmed. Echoed so a stale answer is recognisable. */
81
+ term: string;
82
+ /**
83
+ * How many hits matched **and were visible to this caller**, before the cap.
84
+ *
85
+ * After the access filter, deliberately. A total counted before it would let
86
+ * a caller learn that eleven more types match "payroll" than they can see,
87
+ * which is the disclosure this route spends most of its code avoiding.
88
+ */
89
+ total: number;
90
+ /** True when {@link total} exceeded the limit and {@link hits} was cut. */
91
+ truncated: boolean;
92
+ hits: CatalogSearchHit[];
93
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ /**
3
+ * What one search across the catalog gives back.
4
+ *
5
+ * A separate file from `search.ts` so `client.ts` can re-export these without
6
+ * dragging the matcher into a browser bundle. The shapes are the contract — a
7
+ * host writing its own search box needs them as much as it needs the path.
8
+ *
9
+ * **Rows, not objects.** Every hit carries what it takes to draw a line and
10
+ * follow it, and nothing else: no `sql`, no property list, no card layout. That
11
+ * is not a size optimisation. A search route is the one endpoint whose result
12
+ * set is chosen by a stranger's typing, so it is the endpoint where "we returned
13
+ * a bit more than the screen needed" turns into a disclosure nobody reviewed. A
14
+ * caller that wants the whole saved query asks `GET saved-queries/:id`, which is
15
+ * a route somebody thought about.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-catalog",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A metadata registry for NestJS: object types, properties and relations, derived from your ORM and enriched with decorators.",
5
5
  "license": "MIT",
6
6
  "author": "Davide Carvalho",