@dudousxd/nestjs-catalog 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.controller.d.ts +6 -0
- package/dist/catalog.controller.js +54 -0
- package/dist/catalog.events.d.ts +72 -1
- package/dist/catalog.events.js +6 -0
- package/dist/catalog.overlay-store.d.ts +22 -1
- package/dist/catalog.overlay-store.js +47 -6
- package/dist/catalog.pipeline.d.ts +22 -1
- package/dist/catalog.registry.base.d.ts +66 -1
- package/dist/catalog.registry.base.js +102 -0
- package/dist/catalog.registry.d.ts +12 -2
- package/dist/catalog.registry.js +39 -68
- package/dist/catalog.service.d.ts +17 -0
- package/dist/catalog.service.js +72 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -2
- package/dist/search.d.ts +184 -0
- package/dist/search.js +345 -0
- package/dist/search.types.d.ts +93 -0
- package/dist/search.types.js +17 -0
- package/dist/transform-runner.js +223 -10
- package/package.json +1 -1
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 });
|