@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,209 @@
1
+ /**
2
+ * Who is talking to the catalog.
3
+ *
4
+ * Once more than one application reads and writes the same catalog, "which app
5
+ * did this" stops being a logging concern and becomes part of the data. A
6
+ * principal is therefore recorded onto every snapshot, not just checked at the
7
+ * door: logs rotate, and the question "who loaded these rows" is asked months
8
+ * later.
9
+ *
10
+ * There are two kinds of caller and they are not the same kind of thing. An
11
+ * *application* acts on its own behalf — a nightly publisher has no human
12
+ * behind it and must never be made to invent one. A *person* acts through an
13
+ * application: someone signed into a console is still reaching the catalog as
14
+ * that console, with that console's ceiling, and the console does not become
15
+ * more powerful because an administrator happened to sign in. Both facts have
16
+ * to survive into the audit trail, because "the console renamed this column" is
17
+ * not an answer anybody accepts.
18
+ */
19
+ export type CatalogScope =
20
+ /** Read object metadata and rows. */
21
+ 'catalog:read'
22
+ /** Load snapshots for the types this principal is granted. */
23
+ | 'catalog:write'
24
+ /** Edit labels, descriptions, units — the presentation layer. */
25
+ | 'catalog:curate'
26
+ /**
27
+ * Fetch shared dashboards and charts through the embed API.
28
+ *
29
+ * Separate from `catalog:read` on purpose: an application that renders one
30
+ * chart in its own UI needs nothing else, and giving it the whole catalog to
31
+ * do that is the kind of over-grant nobody revisits.
32
+ */
33
+ | 'catalog:embed'
34
+ /** Manage principals and grants. */
35
+ | 'catalog:admin';
36
+ /**
37
+ * The separator between the application half of a principal id and the person
38
+ * on whose behalf it acted: `catalog-console#ana@example.com`.
39
+ *
40
+ * A composite string rather than a second column, because `principalId` is
41
+ * already load-bearing in places this type cannot reach — it is a column on
42
+ * every snapshot row, an index on the audit table, and the owner key that
43
+ * decides which application may re-publish a type. Adding a parallel `actorId`
44
+ * everywhere means every one of those readers has to be found and taught about
45
+ * it, and the ones that are missed silently keep reporting a person's work as
46
+ * the console's.
47
+ *
48
+ * The convention is chosen so that nothing already written has to change:
49
+ * an id with no separator is exactly what it always was, an application acting
50
+ * alone. Old rows parse correctly because they parse as themselves.
51
+ *
52
+ * `#` specifically because it cannot appear in an OIDC client id and does not
53
+ * appear in a practical email address, so a round trip through
54
+ * {@link composePrincipalId} and {@link parsePrincipalId} is lossless. Callers
55
+ * that mint actor ids are expected to reject the separator rather than escape
56
+ * it — an escaping scheme here would be a second thing to get wrong.
57
+ */
58
+ export declare const PRINCIPAL_ACTOR_SEPARATOR = "#";
59
+ /**
60
+ * The person behind a request, when there is one.
61
+ *
62
+ * Deliberately thin: an id and a name to show. Everything else about a human —
63
+ * where their password lives, which directory they came from, what they are
64
+ * allowed — is the host's business, and a library that started modelling users
65
+ * would be a library that has opinions about your identity provider.
66
+ */
67
+ export interface CatalogActor {
68
+ /**
69
+ * Stable login, e.g. an email address or an OIDC `sub`. It ends up inside
70
+ * every `principalId` this actor's session produces, so it must be stable
71
+ * across sessions and must not contain {@link PRINCIPAL_ACTOR_SEPARATOR}.
72
+ */
73
+ id: string;
74
+ displayName?: string;
75
+ }
76
+ /** What a caller may do, independent of who the caller is. */
77
+ export interface CatalogGrants {
78
+ scopes: CatalogScope[];
79
+ writeTypes?: string[];
80
+ readTypes?: string[];
81
+ classifications?: string[];
82
+ }
83
+ /** A calling application, optionally acting for a person. */
84
+ export interface CatalogPrincipal {
85
+ /**
86
+ * Stable identifier, recorded onto every snapshot this principal writes.
87
+ * With OIDC client credentials this is the token's `azp` — the client id.
88
+ *
89
+ * For a delegated principal this is the composite
90
+ * `<applicationId>#<actor.id>`. Read it with {@link parsePrincipalId} rather
91
+ * than comparing it whole: an equality check against an application id is
92
+ * correct for machine callers and quietly false for every human one.
93
+ */
94
+ id: string;
95
+ displayName?: string;
96
+ /**
97
+ * The application half of {@link id}, always set for a delegated principal.
98
+ *
99
+ * Carried explicitly as well as encoded in the id so live code never has to
100
+ * re-parse a string it just built. Both are produced together by
101
+ * {@link delegatePrincipal}, so they cannot drift; parsing is for rows read
102
+ * back out of the database, where only the string survived.
103
+ */
104
+ applicationId?: string;
105
+ /** The person this principal is acting for. Absent means a machine caller. */
106
+ actor?: CatalogActor;
107
+ scopes: CatalogScope[];
108
+ /**
109
+ * Object types this principal may load. `["*"]` for all.
110
+ *
111
+ * Per-type rather than a single global write scope on purpose: a shared write
112
+ * path where every caller can write every type means one application can
113
+ * quietly overwrite another's data, and the blast radius of a leaked
114
+ * credential is the whole catalog.
115
+ */
116
+ writeTypes?: string[];
117
+ /** Object types this principal may read. `["*"]` or undefined for all. */
118
+ readTypes?: string[];
119
+ /**
120
+ * Classifications this principal may see. A column marked with anything
121
+ * outside this list is dropped from its reads.
122
+ *
123
+ * Undefined means "no classified columns", not "all of them" — the safe
124
+ * default for a caller nobody has thought about yet.
125
+ */
126
+ classifications?: string[];
127
+ }
128
+ /**
129
+ * Turns a request into a principal.
130
+ *
131
+ * An interface rather than an implementation because the right answer depends
132
+ * on infrastructure the library cannot see. An app that already runs an OIDC
133
+ * provider should resolve the access token's `azp` and get rotation, expiry and
134
+ * central revocation for free; building a table of long-lived API keys beside a
135
+ * working identity provider is strictly worse.
136
+ */
137
+ export interface CatalogPrincipalResolver {
138
+ resolve(request: unknown): Promise<CatalogPrincipal | null>;
139
+ }
140
+ export declare const CATALOG_PRINCIPAL_RESOLVER: unique symbol;
141
+ /**
142
+ * A header-keyed resolver, for local development and for callers that live
143
+ * outside the identity provider.
144
+ *
145
+ * Explicitly the lesser option. Keys here are long-lived, revoked only by
146
+ * redeploying, and as good as their storage — prefer a token-based resolver
147
+ * wherever there is an IdP to resolve against.
148
+ */
149
+ export declare class StaticKeyPrincipalResolver implements CatalogPrincipalResolver {
150
+ private readonly header;
151
+ private readonly byKey;
152
+ constructor(principals: Array<CatalogPrincipal & {
153
+ key: string;
154
+ }>, header?: string);
155
+ resolve(request: unknown): Promise<CatalogPrincipal | null>;
156
+ }
157
+ export declare function hasScope(principal: CatalogPrincipal, scope: CatalogScope): boolean;
158
+ /** Builds the composite id. Returns the bare application id when nobody is behind it. */
159
+ export declare function composePrincipalId(applicationId: string, actorId?: string): string;
160
+ /**
161
+ * Splits a recorded `principalId` back into its two halves.
162
+ *
163
+ * Total, and lenient by design: every id ever written parses, including the
164
+ * millions written before actors existed. This is what lets a governance query
165
+ * keep asking "everything `flip-nestjs` did" — it matches on `applicationId`
166
+ * and gets both the machine's own loads and anything a person did through it.
167
+ *
168
+ * Splits on the *first* separator, so an actor id that somehow contains one
169
+ * still yields the right application rather than the right-hand fragment.
170
+ */
171
+ export declare function parsePrincipalId(principalId: string): {
172
+ applicationId: string;
173
+ actorId?: string;
174
+ };
175
+ /**
176
+ * `catalog:admin` written out.
177
+ *
178
+ * {@link hasScope} treats admin as implying everything, which is convenient at
179
+ * a gate and actively wrong when two scope lists have to be *combined*: a naive
180
+ * set intersection of `[catalog:admin]` with `[catalog:read]` is empty, and the
181
+ * person who should have kept read access is locked out instead. Expand first,
182
+ * intersect second.
183
+ */
184
+ export declare function expandScopes(scopes: CatalogScope[]): CatalogScope[];
185
+ /**
186
+ * A principal for a person acting through an application.
187
+ *
188
+ * Every grant is the **intersection** of the two, never the union, and that is
189
+ * the whole point of this function. Signing in must not be a way to acquire
190
+ * what the application could not do — otherwise the console's own key stops
191
+ * being a ceiling and becomes decoration — and using the console must not be a
192
+ * way to acquire what the person was not granted. Both failures look identical
193
+ * from the outside: someone reads a table they were never meant to see, and
194
+ * the audit trail records it as perfectly authorised.
195
+ *
196
+ * The consequence worth stating out loud: raising what people can do in a
197
+ * console is an operator action on the console's *application* principal, not
198
+ * something that happens because an administrator logged in.
199
+ */
200
+ export declare function delegatePrincipal(application: CatalogPrincipal, actor: CatalogActor, grants: CatalogGrants): CatalogPrincipal;
201
+ export declare function mayWrite(principal: CatalogPrincipal, typeName: string): boolean;
202
+ export declare function mayRead(principal: CatalogPrincipal, typeName: string): boolean;
203
+ /**
204
+ * Whether a column is visible to this principal.
205
+ *
206
+ * Unclassified columns are visible to everyone; a classified one requires the
207
+ * principal to name that classification. Absence is denial.
208
+ */
209
+ export declare function maySeeClassification(principal: CatalogPrincipal, classification: string | undefined): boolean;
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ /**
3
+ * Who is talking to the catalog.
4
+ *
5
+ * Once more than one application reads and writes the same catalog, "which app
6
+ * did this" stops being a logging concern and becomes part of the data. A
7
+ * principal is therefore recorded onto every snapshot, not just checked at the
8
+ * door: logs rotate, and the question "who loaded these rows" is asked months
9
+ * later.
10
+ *
11
+ * There are two kinds of caller and they are not the same kind of thing. An
12
+ * *application* acts on its own behalf — a nightly publisher has no human
13
+ * behind it and must never be made to invent one. A *person* acts through an
14
+ * application: someone signed into a console is still reaching the catalog as
15
+ * that console, with that console's ceiling, and the console does not become
16
+ * more powerful because an administrator happened to sign in. Both facts have
17
+ * to survive into the audit trail, because "the console renamed this column" is
18
+ * not an answer anybody accepts.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.StaticKeyPrincipalResolver = exports.CATALOG_PRINCIPAL_RESOLVER = exports.PRINCIPAL_ACTOR_SEPARATOR = void 0;
22
+ exports.hasScope = hasScope;
23
+ exports.composePrincipalId = composePrincipalId;
24
+ exports.parsePrincipalId = parsePrincipalId;
25
+ exports.expandScopes = expandScopes;
26
+ exports.delegatePrincipal = delegatePrincipal;
27
+ exports.mayWrite = mayWrite;
28
+ exports.mayRead = mayRead;
29
+ exports.maySeeClassification = maySeeClassification;
30
+ /** Every scope, in one place, so `catalog:admin` can be expanded exactly. */
31
+ const ALL_SCOPES = [
32
+ 'catalog:read',
33
+ 'catalog:write',
34
+ 'catalog:curate',
35
+ 'catalog:embed',
36
+ 'catalog:admin',
37
+ ];
38
+ /**
39
+ * The separator between the application half of a principal id and the person
40
+ * on whose behalf it acted: `catalog-console#ana@example.com`.
41
+ *
42
+ * A composite string rather than a second column, because `principalId` is
43
+ * already load-bearing in places this type cannot reach — it is a column on
44
+ * every snapshot row, an index on the audit table, and the owner key that
45
+ * decides which application may re-publish a type. Adding a parallel `actorId`
46
+ * everywhere means every one of those readers has to be found and taught about
47
+ * it, and the ones that are missed silently keep reporting a person's work as
48
+ * the console's.
49
+ *
50
+ * The convention is chosen so that nothing already written has to change:
51
+ * an id with no separator is exactly what it always was, an application acting
52
+ * alone. Old rows parse correctly because they parse as themselves.
53
+ *
54
+ * `#` specifically because it cannot appear in an OIDC client id and does not
55
+ * appear in a practical email address, so a round trip through
56
+ * {@link composePrincipalId} and {@link parsePrincipalId} is lossless. Callers
57
+ * that mint actor ids are expected to reject the separator rather than escape
58
+ * it — an escaping scheme here would be a second thing to get wrong.
59
+ */
60
+ exports.PRINCIPAL_ACTOR_SEPARATOR = '#';
61
+ exports.CATALOG_PRINCIPAL_RESOLVER = Symbol('CATALOG_PRINCIPAL_RESOLVER');
62
+ /**
63
+ * A header-keyed resolver, for local development and for callers that live
64
+ * outside the identity provider.
65
+ *
66
+ * Explicitly the lesser option. Keys here are long-lived, revoked only by
67
+ * redeploying, and as good as their storage — prefer a token-based resolver
68
+ * wherever there is an IdP to resolve against.
69
+ */
70
+ class StaticKeyPrincipalResolver {
71
+ header;
72
+ byKey;
73
+ constructor(principals, header = 'x-catalog-key') {
74
+ this.header = header;
75
+ this.byKey = new Map(principals.map(({ key, ...principal }) => [key, principal]));
76
+ }
77
+ async resolve(request) {
78
+ if (!request || typeof request !== 'object')
79
+ return null;
80
+ const headers = Reflect.get(request, 'headers');
81
+ if (!headers || typeof headers !== 'object')
82
+ return null;
83
+ const presented = Reflect.get(headers, this.header);
84
+ if (typeof presented !== 'string')
85
+ return null;
86
+ return this.byKey.get(presented) ?? null;
87
+ }
88
+ }
89
+ exports.StaticKeyPrincipalResolver = StaticKeyPrincipalResolver;
90
+ function hasScope(principal, scope) {
91
+ return principal.scopes.includes(scope) || principal.scopes.includes('catalog:admin');
92
+ }
93
+ /** Builds the composite id. Returns the bare application id when nobody is behind it. */
94
+ function composePrincipalId(applicationId, actorId) {
95
+ if (!actorId)
96
+ return applicationId;
97
+ return `${applicationId}${exports.PRINCIPAL_ACTOR_SEPARATOR}${actorId}`;
98
+ }
99
+ /**
100
+ * Splits a recorded `principalId` back into its two halves.
101
+ *
102
+ * Total, and lenient by design: every id ever written parses, including the
103
+ * millions written before actors existed. This is what lets a governance query
104
+ * keep asking "everything `flip-nestjs` did" — it matches on `applicationId`
105
+ * and gets both the machine's own loads and anything a person did through it.
106
+ *
107
+ * Splits on the *first* separator, so an actor id that somehow contains one
108
+ * still yields the right application rather than the right-hand fragment.
109
+ */
110
+ function parsePrincipalId(principalId) {
111
+ const at = principalId.indexOf(exports.PRINCIPAL_ACTOR_SEPARATOR);
112
+ if (at < 0)
113
+ return { applicationId: principalId };
114
+ const actorId = principalId.slice(at + exports.PRINCIPAL_ACTOR_SEPARATOR.length);
115
+ return {
116
+ applicationId: principalId.slice(0, at),
117
+ // A trailing separator with nothing after it is a malformed id, not an
118
+ // actor named "". Treating it as a machine caller is the safer reading:
119
+ // it under-claims attribution rather than inventing a person.
120
+ actorId: actorId.length > 0 ? actorId : undefined,
121
+ };
122
+ }
123
+ /**
124
+ * `catalog:admin` written out.
125
+ *
126
+ * {@link hasScope} treats admin as implying everything, which is convenient at
127
+ * a gate and actively wrong when two scope lists have to be *combined*: a naive
128
+ * set intersection of `[catalog:admin]` with `[catalog:read]` is empty, and the
129
+ * person who should have kept read access is locked out instead. Expand first,
130
+ * intersect second.
131
+ */
132
+ function expandScopes(scopes) {
133
+ if (scopes.includes('catalog:admin'))
134
+ return [...ALL_SCOPES];
135
+ return [...scopes];
136
+ }
137
+ /**
138
+ * A principal for a person acting through an application.
139
+ *
140
+ * Every grant is the **intersection** of the two, never the union, and that is
141
+ * the whole point of this function. Signing in must not be a way to acquire
142
+ * what the application could not do — otherwise the console's own key stops
143
+ * being a ceiling and becomes decoration — and using the console must not be a
144
+ * way to acquire what the person was not granted. Both failures look identical
145
+ * from the outside: someone reads a table they were never meant to see, and
146
+ * the audit trail records it as perfectly authorised.
147
+ *
148
+ * The consequence worth stating out loud: raising what people can do in a
149
+ * console is an operator action on the console's *application* principal, not
150
+ * something that happens because an administrator logged in.
151
+ */
152
+ function delegatePrincipal(application, actor, grants) {
153
+ if (actor.id.includes(exports.PRINCIPAL_ACTOR_SEPARATOR)) {
154
+ throw new Error(`Actor id "${actor.id}" contains ${exports.PRINCIPAL_ACTOR_SEPARATOR}, which would make its principal id ambiguous.`);
155
+ }
156
+ const applicationId = application.applicationId ?? application.id;
157
+ const appScopes = expandScopes(application.scopes);
158
+ const actorScopes = expandScopes(grants.scopes);
159
+ return {
160
+ id: composePrincipalId(applicationId, actor.id),
161
+ applicationId,
162
+ actor,
163
+ displayName: actor.displayName
164
+ ? `${actor.displayName} via ${application.displayName ?? applicationId}`
165
+ : applicationId,
166
+ scopes: appScopes.filter((scope) => actorScopes.includes(scope)),
167
+ // Absent means "nothing" on both sides — an unlisted type is a denied
168
+ // write — so `["*"]` is the only way either side says "all of them".
169
+ writeTypes: intersectAllowList(application.writeTypes, grants.writeTypes, 'none'),
170
+ // Absent means "everything" here, matching `mayRead`. Preserving that
171
+ // through the intersection is what keeps a plain reader from having to
172
+ // enumerate every type that will ever exist.
173
+ readTypes: intersectAllowList(application.readTypes, grants.readTypes, 'all'),
174
+ // Classifications get a plain set intersection with no wildcard, because
175
+ // `maySeeClassification` does not honour `"*"` — it asks for the exact
176
+ // label. Emitting `["*"]` here would produce a principal that looks
177
+ // all-seeing and can in fact see nothing classified at all.
178
+ classifications: (application.classifications ?? []).filter((label) => (grants.classifications ?? []).includes(label)),
179
+ };
180
+ }
181
+ /**
182
+ * Intersects two allow-lists that both use `"*"` for "all", where an absent
183
+ * list means `whenAbsent`.
184
+ *
185
+ * Returns `undefined` for "all" only when absence already means all on that
186
+ * axis, so a read intersection stays in the compact `undefined` form that
187
+ * `mayRead` understands rather than being expanded into a list that goes stale
188
+ * the moment a new type is published.
189
+ */
190
+ function intersectAllowList(a, b, whenAbsent) {
191
+ const left = normaliseAllowList(a, whenAbsent);
192
+ const right = normaliseAllowList(b, whenAbsent);
193
+ // "Nothing" on either side settles it before anything else is considered:
194
+ // intersecting with an empty set is empty whatever the other side says.
195
+ if (left === 'none' || right === 'none')
196
+ return [];
197
+ // Each remaining test compares against the *same* variable it narrows.
198
+ // Deciding "exactly one side is all" with a compound condition reads fine and
199
+ // does not narrow — the compiler cannot carry `right !== "all"` out of an
200
+ // earlier `left === "all" && right === "all"`, so the branch below it still
201
+ // has `"all"` in its type and the function stops compiling.
202
+ if (left === 'all') {
203
+ if (right === 'all')
204
+ return whenAbsent === 'all' ? undefined : ['*'];
205
+ return right;
206
+ }
207
+ if (right === 'all')
208
+ return left;
209
+ return left.filter((name) => right.includes(name));
210
+ }
211
+ function normaliseAllowList(list, whenAbsent) {
212
+ if (list === undefined)
213
+ return whenAbsent;
214
+ if (list.includes('*'))
215
+ return 'all';
216
+ if (list.length === 0)
217
+ return 'none';
218
+ return list;
219
+ }
220
+ function matches(list, typeName) {
221
+ if (!list)
222
+ return false;
223
+ return list.includes('*') || list.includes(typeName);
224
+ }
225
+ function mayWrite(principal, typeName) {
226
+ return hasScope(principal, 'catalog:write') && matches(principal.writeTypes, typeName);
227
+ }
228
+ function mayRead(principal, typeName) {
229
+ if (!hasScope(principal, 'catalog:read'))
230
+ return false;
231
+ // Undefined readTypes means every type, which is the useful default for a
232
+ // read-only consumer. Write grants get no such default — see `writeTypes`.
233
+ return principal.readTypes === undefined || matches(principal.readTypes, typeName);
234
+ }
235
+ /**
236
+ * Whether a column is visible to this principal.
237
+ *
238
+ * Unclassified columns are visible to everyone; a classified one requires the
239
+ * principal to name that classification. Absence is denial.
240
+ */
241
+ function maySeeClassification(principal, classification) {
242
+ if (!classification)
243
+ return true;
244
+ return principal.classifications?.includes(classification) ?? false;
245
+ }
@@ -0,0 +1,25 @@
1
+ import type { CatalogQueryResult } from './catalog.query';
2
+ /**
3
+ * A small in-process cache for query results.
4
+ *
5
+ * In-process on purpose. A shared cache would need invalidation on every
6
+ * commit from every publisher, across every pod, and getting that wrong means
7
+ * showing someone last week's numbers with this week's confidence. A per-pod
8
+ * cache with a short TTL is a worse cache and a much better failure mode: the
9
+ * staleness is bounded by a number the query's author chose.
10
+ *
11
+ * Keyed on the SQL *and* the catalog version, so a curation edit that renames a
12
+ * column cannot serve a result computed under the old name.
13
+ */
14
+ export declare class QueryCache {
15
+ private readonly maxEntries;
16
+ private readonly entries;
17
+ constructor(maxEntries?: number);
18
+ static key(sql: string, catalogVersion: number): string;
19
+ get(key: string): CatalogQueryResult | undefined;
20
+ set(key: string, result: CatalogQueryResult, ttlSeconds: number): void;
21
+ clear(): void;
22
+ get size(): number;
23
+ }
24
+ /** CSV, for the export button. */
25
+ export declare function toCsv(result: CatalogQueryResult): string;
Binary file
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Ad-hoc SQL over the catalogued data.
3
+ *
4
+ * Separate from `CatalogReadStore` because not every store can offer it: a
5
+ * read-through view of someone else's tables has no business handing out a SQL
6
+ * console over them, and a store fronting an API has no SQL to run.
7
+ */
8
+ export interface CatalogQueryRequest {
9
+ sql: string;
10
+ /** Hard cap on rows returned. The service clamps this. */
11
+ maxRows?: number;
12
+ /**
13
+ * How long the statement may run, in milliseconds — **best effort**.
14
+ *
15
+ * Unlike `maxRows`, which the store enforces itself by wrapping the statement,
16
+ * this is handed to the engine and the engine decides what to do with it. What
17
+ * that buys varies more than it looks:
18
+ *
19
+ * - MySQL's `MAX_EXECUTION_TIME` applies to read-only SELECTs and is a hint
20
+ * the optimiser checks between stages, so a statement can overrun it.
21
+ * - ClickHouse's `max_execution_time` is likewise checked between blocks.
22
+ * - An engine with no statement-level timeout at all can only honour this by
23
+ * abandoning the client's side of the connection, which stops the caller
24
+ * waiting and does not stop the query.
25
+ *
26
+ * So a caller may not treat this as a guarantee that resources are released
27
+ * when it elapses. The honest reading is "the store will ask, and will stop
28
+ * waiting"; a deployment that needs a hard bound sets one on the database, in
29
+ * the role the catalog's read connection uses.
30
+ */
31
+ timeoutMs?: number;
32
+ }
33
+ export interface CatalogQueryResult {
34
+ /** True when this came from the cache rather than the database. */
35
+ cached?: boolean;
36
+ columns: string[];
37
+ rows: Array<Record<string, unknown>>;
38
+ rowCount: number;
39
+ /** True when the cap cut the result short, so the UI can say so. */
40
+ truncated: boolean;
41
+ elapsedMs: number;
42
+ }
43
+ /** One queryable relation, for the editor's schema panel and autocomplete. */
44
+ export interface CatalogQueryRelation {
45
+ /** What to write in a FROM clause. */
46
+ name: string;
47
+ kind: 'current' | 'history';
48
+ objectType: string;
49
+ description: string;
50
+ columns: Array<{
51
+ name: string;
52
+ type: string;
53
+ }>;
54
+ }
55
+ export interface CatalogQueryStore {
56
+ /**
57
+ * Run a read-only statement.
58
+ *
59
+ * Implementations are expected to enforce read-only at the *database*, not by
60
+ * inspecting the string: a keyword denylist is a guess about a parser, and
61
+ * the parser always wins eventually.
62
+ */
63
+ runQuery(request: CatalogQueryRequest): Promise<CatalogQueryResult>;
64
+ /** What a query may select from. */
65
+ queryRelations(): Promise<CatalogQueryRelation[]>;
66
+ }
67
+ export declare function isQueryStore(store: unknown): store is CatalogQueryStore;
68
+ /**
69
+ * A cheap sanity check on the shape of a statement.
70
+ *
71
+ * Explicitly NOT the security boundary — that is the read-only transaction the
72
+ * store opens. This exists to turn "you typed an UPDATE" into a clear message
73
+ * instead of a database error, and to refuse the multi-statement form outright
74
+ * since nothing legitimate here needs it.
75
+ */
76
+ export declare function assertReadOnlyShape(sql: string): void;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * Ad-hoc SQL over the catalogued data.
4
+ *
5
+ * Separate from `CatalogReadStore` because not every store can offer it: a
6
+ * read-through view of someone else's tables has no business handing out a SQL
7
+ * console over them, and a store fronting an API has no SQL to run.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.isQueryStore = isQueryStore;
11
+ exports.assertReadOnlyShape = assertReadOnlyShape;
12
+ function isQueryStore(store) {
13
+ return (typeof store === 'object' &&
14
+ store !== null &&
15
+ typeof Reflect.get(store, 'runQuery') === 'function');
16
+ }
17
+ /**
18
+ * A cheap sanity check on the shape of a statement.
19
+ *
20
+ * Explicitly NOT the security boundary — that is the read-only transaction the
21
+ * store opens. This exists to turn "you typed an UPDATE" into a clear message
22
+ * instead of a database error, and to refuse the multi-statement form outright
23
+ * since nothing legitimate here needs it.
24
+ */
25
+ function assertReadOnlyShape(sql) {
26
+ const trimmed = sql.trim().replace(/;\s*$/, '');
27
+ if (trimmed.length === 0) {
28
+ throw new Error('The query is empty.');
29
+ }
30
+ if (trimmed.includes(';')) {
31
+ throw new Error('Only one statement at a time. Remove the semicolon in the middle.');
32
+ }
33
+ // Leading comments have to come off before the keyword test. People paste
34
+ // annotated SQL and they write notes above the statement — rejecting that as
35
+ // "not a SELECT" is both wrong and baffling, since the SELECT is right there.
36
+ const statement = stripLeadingComments(trimmed);
37
+ if (statement.length === 0) {
38
+ throw new Error('That is all comments — there is no statement to run.');
39
+ }
40
+ if (!/^(select|with)\b/i.test(statement)) {
41
+ throw new Error('Only SELECT (or WITH … SELECT) can run here. The catalog is read-only from this screen.');
42
+ }
43
+ }
44
+ /** Strips `-- line` and `/* block *\/` comments from the front of a statement. */
45
+ function stripLeadingComments(sql) {
46
+ let rest = sql.trimStart();
47
+ for (;;) {
48
+ if (rest.startsWith('--') || rest.startsWith('#')) {
49
+ const newline = rest.indexOf('\n');
50
+ if (newline === -1)
51
+ return '';
52
+ rest = rest.slice(newline + 1).trimStart();
53
+ continue;
54
+ }
55
+ if (rest.startsWith('/*')) {
56
+ const close = rest.indexOf('*/');
57
+ if (close === -1)
58
+ return '';
59
+ rest = rest.slice(close + 2).trimStart();
60
+ continue;
61
+ }
62
+ return rest;
63
+ }
64
+ }
@@ -0,0 +1,21 @@
1
+ import type { CatalogGraph, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
2
+ /**
3
+ * What the catalog knows about your types, however it came to know it.
4
+ *
5
+ * An abstract class rather than an interface so it doubles as the DI token.
6
+ *
7
+ * Two implementations are expected and they are genuinely different: one
8
+ * *derives* the model from an ORM in the application that owns the tables, and
9
+ * one *stores* it because the model arrived over the wire from somewhere else.
10
+ * A warehouse has no entity classes to reflect over — the type definitions are
11
+ * data it was handed. Everything above this line works the same either way.
12
+ */
13
+ export declare abstract class CatalogRegistry {
14
+ abstract getSnapshot(): CatalogSnapshot;
15
+ abstract getType(name: string): CatalogObjectTypeDef | undefined;
16
+ abstract getGraph(): CatalogGraph;
17
+ /** Presentation-only edits. Never a schema change. */
18
+ abstract patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>): Promise<CatalogObjectTypeDef | undefined>;
19
+ abstract patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string]): Promise<CatalogObjectTypeDef | undefined>;
20
+ abstract resetOverlay(): Promise<void>;
21
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CatalogRegistry = void 0;
4
+ /**
5
+ * What the catalog knows about your types, however it came to know it.
6
+ *
7
+ * An abstract class rather than an interface so it doubles as the DI token.
8
+ *
9
+ * Two implementations are expected and they are genuinely different: one
10
+ * *derives* the model from an ORM in the application that owns the tables, and
11
+ * one *stores* it because the model arrived over the wire from somewhere else.
12
+ * A warehouse has no entity classes to reflect over — the type definitions are
13
+ * data it was handed. Everything above this line works the same either way.
14
+ */
15
+ class CatalogRegistry {
16
+ }
17
+ exports.CatalogRegistry = CatalogRegistry;