@dudousxd/nestjs-catalog 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/dist/catalog.controller.d.ts +8 -0
  4. package/dist/catalog.controller.js +482 -0
  5. package/dist/catalog.decorators.d.ts +37 -0
  6. package/dist/catalog.decorators.js +50 -0
  7. package/dist/catalog.environment.d.ts +442 -0
  8. package/dist/catalog.environment.js +645 -0
  9. package/dist/catalog.events.d.ts +179 -0
  10. package/dist/catalog.events.js +110 -0
  11. package/dist/catalog.module.d.ts +5 -0
  12. package/dist/catalog.module.js +71 -0
  13. package/dist/catalog.options.d.ts +79 -0
  14. package/dist/catalog.options.js +4 -0
  15. package/dist/catalog.overlay-store.d.ts +25 -0
  16. package/dist/catalog.overlay-store.js +44 -0
  17. package/dist/catalog.overlay-store.token.d.ts +1 -0
  18. package/dist/catalog.overlay-store.token.js +4 -0
  19. package/dist/catalog.pipeline.d.ts +800 -0
  20. package/dist/catalog.pipeline.js +606 -0
  21. package/dist/catalog.principal.d.ts +209 -0
  22. package/dist/catalog.principal.js +245 -0
  23. package/dist/catalog.query-cache.d.ts +25 -0
  24. package/dist/catalog.query-cache.js +0 -0
  25. package/dist/catalog.query.d.ts +76 -0
  26. package/dist/catalog.query.js +64 -0
  27. package/dist/catalog.registry.base.d.ts +21 -0
  28. package/dist/catalog.registry.base.js +17 -0
  29. package/dist/catalog.registry.d.ts +44 -0
  30. package/dist/catalog.registry.js +359 -0
  31. package/dist/catalog.service.d.ts +115 -0
  32. package/dist/catalog.service.js +366 -0
  33. package/dist/catalog.store.d.ts +419 -0
  34. package/dist/catalog.store.js +175 -0
  35. package/dist/catalog.types.d.ts +165 -0
  36. package/dist/catalog.types.js +19 -0
  37. package/dist/catalog.workspace.d.ts +426 -0
  38. package/dist/catalog.workspace.js +87 -0
  39. package/dist/client.d.ts +86 -0
  40. package/dist/client.js +83 -0
  41. package/dist/index.d.ts +19 -0
  42. package/dist/index.js +109 -0
  43. package/dist/stores/mikro-orm-read.store.d.ts +20 -0
  44. package/dist/stores/mikro-orm-read.store.js +120 -0
  45. package/dist/transform-runner.d.ts +54 -0
  46. package/dist/transform-runner.js +280 -0
  47. package/package.json +54 -0
@@ -0,0 +1,366 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.CatalogService = void 0;
16
+ const common_1 = require("@nestjs/common");
17
+ const catalog_options_1 = require("./catalog.options");
18
+ const catalog_query_1 = require("./catalog.query");
19
+ const catalog_query_cache_1 = require("./catalog.query-cache");
20
+ const catalog_registry_base_1 = require("./catalog.registry.base");
21
+ const catalog_store_1 = require("./catalog.store");
22
+ const catalog_workspace_1 = require("./catalog.workspace");
23
+ const DEFAULT_PAGE_SIZE = 25;
24
+ const DEFAULT_MAX_PAGE_SIZE = 200;
25
+ /**
26
+ * Reads objects of any catalogued type through one endpoint.
27
+ *
28
+ * This layer owns the decisions that must hold no matter where the rows live:
29
+ * which type names are real, which columns may be returned, how large a page
30
+ * may be. The store below it only fetches. Keeping the guardrails here means a
31
+ * new store cannot accidentally relax them — the appeal of a generic read
32
+ * endpoint is also its whole risk.
33
+ */
34
+ let CatalogService = class CatalogService {
35
+ registry;
36
+ store;
37
+ options;
38
+ workspace;
39
+ constructor(registry, store, options, workspace) {
40
+ this.registry = registry;
41
+ this.store = store;
42
+ this.options = options;
43
+ this.workspace = workspace;
44
+ }
45
+ cache = new catalog_query_cache_1.QueryCache();
46
+ // ---------------------------------------------------------------------------
47
+ // The facade.
48
+ //
49
+ // Everything the built-in controller does is reachable from this one class, by
50
+ // class injection — no symbol to import, no second provider to remember. An
51
+ // app that wants its own routes injects `CatalogService`, writes the
52
+ // controller it wants, and passes `controller: false` so nothing is mounted
53
+ // twice. The registry and the store stay available for anyone who needs them,
54
+ // but nobody should have to reach for them to build an ordinary endpoint.
55
+ // ---------------------------------------------------------------------------
56
+ /** The whole model, as data. */
57
+ getSnapshot() {
58
+ return this.registry.getSnapshot();
59
+ }
60
+ getType(name) {
61
+ return this.registry.getType(name);
62
+ }
63
+ /** Nodes and edges, for drawing the model. */
64
+ getGraph() {
65
+ return this.registry.getGraph();
66
+ }
67
+ /** Presentation-only. Never a schema change. */
68
+ patchType(typeName, patch) {
69
+ return this.registry.patchType(typeName, patch);
70
+ }
71
+ patchProperty(typeName, propertyName, patch) {
72
+ return this.registry.patchProperty(typeName, propertyName, patch);
73
+ }
74
+ /** Drops every runtime edit, where the registry supports it. */
75
+ resetOverlay() {
76
+ return this.registry.resetOverlay();
77
+ }
78
+ /** Columns a generic UI may render: visible, and not a blob. */
79
+ visibleColumns(type) {
80
+ return type.properties.filter((p) => !p.hidden && p.type !== 'json');
81
+ }
82
+ async readObjects(typeName, query) {
83
+ const type = this.registry.getType(typeName);
84
+ if (!type)
85
+ throw new common_1.NotFoundException(`Unknown object type: ${typeName}`);
86
+ const maxPageSize = this.options.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE;
87
+ const size = Math.min(Math.max(Number(query.size) || DEFAULT_PAGE_SIZE, 1), maxPageSize);
88
+ const page = Math.max(Number(query.page) || 1, 1);
89
+ const columns = this.visibleColumns(type);
90
+ if (columns.length === 0) {
91
+ throw new common_1.BadRequestException(`Object type ${type.name} has no readable columns`);
92
+ }
93
+ // Always fetch the primary key: the UI needs a stable row identity even
94
+ // when the key is hidden from display.
95
+ const fields = Array.from(new Set([...type.primaryKey, ...columns.map((c) => c.name)]));
96
+ // Asking for a snapshot a store cannot serve is a caller error, not
97
+ // something to silently answer with current state — a reader who thinks
98
+ // they are looking at last Tuesday and is not would rather be told.
99
+ if (query.snapshot && !this.store.capabilities.timeTravel) {
100
+ throw new common_1.BadRequestException("This catalog's store keeps no history, so it cannot read a snapshot.");
101
+ }
102
+ // Sort is validated here rather than in the store: an unrecognised column
103
+ // must never reach a query builder, whatever the engine.
104
+ const sort = columns.some((c) => c.name === query.sort) ? query.sort : undefined;
105
+ const { rows, total } = await this.store.read(type, fields, {
106
+ page,
107
+ size,
108
+ search: query.search,
109
+ sort,
110
+ dir: query.dir === 'desc' ? 'desc' : 'asc',
111
+ snapshot: query.snapshot,
112
+ });
113
+ return {
114
+ type: type.name,
115
+ page,
116
+ size,
117
+ total,
118
+ pages: Math.max(Math.ceil(total / size), 1),
119
+ columns: columns.map((c) => ({
120
+ name: c.name,
121
+ displayName: c.displayName,
122
+ type: c.type,
123
+ classification: c.classification,
124
+ unit: c.unit,
125
+ })),
126
+ rows,
127
+ };
128
+ }
129
+ /** Empty when the store keeps no history. */
130
+ async listSnapshots(typeName) {
131
+ const type = this.registry.getType(typeName);
132
+ if (!type)
133
+ throw new common_1.NotFoundException(`Unknown object type: ${typeName}`);
134
+ if (!this.store.listSnapshots)
135
+ return [];
136
+ return this.store.listSnapshots(type);
137
+ }
138
+ /** What the mounted store can do — the screens branch on this. */
139
+ capabilities() {
140
+ return {
141
+ ...this.store.capabilities,
142
+ query: (0, catalog_query_1.isQueryStore)(this.store),
143
+ };
144
+ }
145
+ /** What a query may select from. Empty when the store offers no SQL. */
146
+ async queryRelations() {
147
+ if (!(0, catalog_query_1.isQueryStore)(this.store))
148
+ return [];
149
+ return this.store.queryRelations();
150
+ }
151
+ /**
152
+ * Run a read-only statement.
153
+ *
154
+ * The shape check here produces a readable error; the actual guarantee is the
155
+ * read-only transaction the store opens, because a keyword denylist is a
156
+ * guess about a parser and the parser wins eventually.
157
+ */
158
+ async runQuery(input) {
159
+ if (!(0, catalog_query_1.isQueryStore)(this.store)) {
160
+ throw new common_1.BadRequestException("This catalog's store does not support SQL queries.");
161
+ }
162
+ try {
163
+ (0, catalog_query_1.assertReadOnlyShape)(input.sql);
164
+ }
165
+ catch (error) {
166
+ throw new common_1.BadRequestException(error instanceof Error ? error.message : String(error));
167
+ }
168
+ const cap = this.options.maxQueryRows ?? 1_000;
169
+ const maxRows = Math.min(Math.max(Number(input.maxRows) || cap, 1), cap);
170
+ // Keyed on the catalog version as well as the SQL: a curation edit that
171
+ // renames a column must not serve a result computed under the old name.
172
+ const ttl = input.cacheTtlSeconds ?? 0;
173
+ const key = catalog_query_cache_1.QueryCache.key(`${input.sql}|${maxRows}`, this.registry.getSnapshot().version);
174
+ if (ttl > 0) {
175
+ const hit = this.cache.get(key);
176
+ if (hit)
177
+ return { ...hit, cached: true };
178
+ }
179
+ const result = await this.store.runQuery({
180
+ sql: input.sql,
181
+ maxRows,
182
+ timeoutMs: this.options.queryTimeoutMs ?? 15_000,
183
+ });
184
+ this.cache.set(key, result, ttl);
185
+ return result;
186
+ }
187
+ /** Drops every cached result. Exposed so a worker can warm from cold. */
188
+ clearQueryCache() {
189
+ this.cache.clear();
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // Workspace: saved queries, dashboards, audit.
193
+ //
194
+ // Every method degrades rather than throws when no workspace store is
195
+ // mounted, because a catalog without one is a legitimate configuration — it
196
+ // simply has no saved queries, and the screens that need them hide.
197
+ // ---------------------------------------------------------------------------
198
+ workspaceAvailable() {
199
+ return this.workspace !== undefined;
200
+ }
201
+ requireWorkspace() {
202
+ if (!this.workspace) {
203
+ throw new common_1.BadRequestException('This catalog has no workspace store, so it cannot keep saved queries or dashboards.');
204
+ }
205
+ return this.workspace;
206
+ }
207
+ listSavedQueries() {
208
+ return this.workspace ? this.workspace.listSavedQueries() : Promise.resolve([]);
209
+ }
210
+ async getSavedQuery(id) {
211
+ const found = await this.requireWorkspace().getSavedQuery(id);
212
+ if (!found)
213
+ throw new common_1.NotFoundException(`No saved query ${id}`);
214
+ return found;
215
+ }
216
+ saveQuery(input, createdBy) {
217
+ if (!input?.name?.trim()) {
218
+ throw new common_1.BadRequestException('A saved query needs a name.');
219
+ }
220
+ (0, catalog_query_1.assertReadOnlyShape)(input.sql ?? '');
221
+ return this.requireWorkspace().saveQuery(input, createdBy);
222
+ }
223
+ async updateSavedQuery(id, input) {
224
+ if (input.sql !== undefined)
225
+ (0, catalog_query_1.assertReadOnlyShape)(input.sql);
226
+ const updated = await this.requireWorkspace().updateSavedQuery(id, input);
227
+ if (!updated)
228
+ throw new common_1.NotFoundException(`No saved query ${id}`);
229
+ return updated;
230
+ }
231
+ deleteSavedQuery(id) {
232
+ return this.requireWorkspace().deleteSavedQuery(id);
233
+ }
234
+ /** Runs a saved query, honouring the TTL it was saved with. */
235
+ async runSavedQuery(id, maxRows) {
236
+ const saved = await this.getSavedQuery(id);
237
+ const result = await this.runQuery({
238
+ sql: saved.sql,
239
+ maxRows,
240
+ cacheTtlSeconds: saved.cacheTtlSeconds,
241
+ });
242
+ return { savedQuery: saved, result };
243
+ }
244
+ listDashboards() {
245
+ return this.workspace ? this.workspace.listDashboards() : Promise.resolve([]);
246
+ }
247
+ async getDashboard(id) {
248
+ const found = await this.requireWorkspace().getDashboard(id);
249
+ if (!found)
250
+ throw new common_1.NotFoundException(`No dashboard ${id}`);
251
+ return found;
252
+ }
253
+ saveDashboard(input, createdBy) {
254
+ if (!input?.name?.trim()) {
255
+ throw new common_1.BadRequestException('A dashboard needs a name.');
256
+ }
257
+ return this.requireWorkspace().saveDashboard(input, createdBy);
258
+ }
259
+ async updateDashboard(id, input) {
260
+ const updated = await this.requireWorkspace().updateDashboard(id, input);
261
+ if (!updated)
262
+ throw new common_1.NotFoundException(`No dashboard ${id}`);
263
+ return updated;
264
+ }
265
+ deleteDashboard(id) {
266
+ return this.requireWorkspace().deleteDashboard(id);
267
+ }
268
+ // ---------------------------------------------------------------------------
269
+ // Embed: what another application's frontend gets.
270
+ //
271
+ // Only what has been explicitly shared. The alternative — deriving access
272
+ // from the types a query touches — means parsing SQL to decide a permission,
273
+ // and a permission that depends on a parser widens silently the first time
274
+ // the parser meets a query it did not expect.
275
+ // ---------------------------------------------------------------------------
276
+ /** Everything shared, so a consumer can discover what it may render. */
277
+ async listEmbeddable() {
278
+ const [dashboards, queries] = await Promise.all([
279
+ this.listDashboards(),
280
+ this.listSavedQueries(),
281
+ ]);
282
+ return {
283
+ dashboards: dashboards
284
+ .filter((d) => d.shared)
285
+ .map((d) => ({
286
+ id: d.id,
287
+ name: d.name,
288
+ description: d.description,
289
+ charts: d.cards.length,
290
+ })),
291
+ charts: queries
292
+ .filter((q) => q.shared)
293
+ .map((q) => ({
294
+ id: q.id,
295
+ name: q.name,
296
+ description: q.description,
297
+ kind: q.visualization.kind,
298
+ })),
299
+ };
300
+ }
301
+ /** One chart, rendered. */
302
+ async embedChart(savedQueryId, layout) {
303
+ const saved = await this.getSavedQuery(savedQueryId);
304
+ if (!saved.shared) {
305
+ throw new common_1.ForbiddenException(`"${saved.name}" has not been shared. Mark it shared in the console to make it embeddable.`);
306
+ }
307
+ const result = await this.runQuery({
308
+ sql: saved.sql,
309
+ cacheTtlSeconds: saved.cacheTtlSeconds,
310
+ });
311
+ return {
312
+ id: saved.id,
313
+ title: saved.name,
314
+ description: saved.description,
315
+ visualization: saved.visualization,
316
+ layout,
317
+ columns: result.columns,
318
+ rows: result.rows,
319
+ rowCount: result.rowCount,
320
+ cached: Boolean(result.cached),
321
+ generatedAt: new Date().toISOString(),
322
+ };
323
+ }
324
+ /** A whole dashboard, every chart resolved. */
325
+ async embedDashboard(dashboardId) {
326
+ const dashboard = await this.getDashboard(dashboardId);
327
+ if (!dashboard.shared) {
328
+ throw new common_1.ForbiddenException(`"${dashboard.name}" has not been shared.`);
329
+ }
330
+ const ordered = [...dashboard.cards].sort((a, b) => a.position - b.position);
331
+ // Sequential, not parallel: every card is a database query, and a shared
332
+ // dashboard is exactly the thing a consumer will poll on a timer.
333
+ const charts = [];
334
+ for (const card of ordered) {
335
+ try {
336
+ charts.push(await this.embedChart(card.savedQueryId, {
337
+ width: card.width,
338
+ position: card.position,
339
+ }));
340
+ }
341
+ catch {
342
+ // A card whose query is unshared or broken is skipped rather than
343
+ // failing the whole dashboard — one bad card should not blank a page.
344
+ }
345
+ }
346
+ return {
347
+ id: dashboard.id,
348
+ name: dashboard.name,
349
+ description: dashboard.description,
350
+ charts,
351
+ generatedAt: new Date().toISOString(),
352
+ };
353
+ }
354
+ listEvents(query) {
355
+ return this.workspace ? this.workspace.listEvents(query) : Promise.resolve([]);
356
+ }
357
+ };
358
+ exports.CatalogService = CatalogService;
359
+ exports.CatalogService = CatalogService = __decorate([
360
+ (0, common_1.Injectable)(),
361
+ __param(1, (0, common_1.Inject)(catalog_store_1.CATALOG_STORE)),
362
+ __param(2, (0, common_1.Inject)(catalog_options_1.CATALOG_OPTIONS)),
363
+ __param(3, (0, common_1.Optional)()),
364
+ __param(3, (0, common_1.Inject)(catalog_workspace_1.CATALOG_WORKSPACE_STORE)),
365
+ __metadata("design:paramtypes", [catalog_registry_base_1.CatalogRegistry, Object, Object, Object])
366
+ ], CatalogService);