@dudousxd/nestjs-catalog 0.14.0 → 0.15.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.
@@ -9,12 +9,13 @@
9
9
  * systems each believing they decide when a load runs.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.TRANSFORM_RUNNER = exports.TRANSFORM_LANGUAGES = exports.CONNECTOR_KINDS = void 0;
12
+ exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.TRANSFORM_RUNNER = exports.TRANSFORM_LANGUAGES = exports.CONNECTOR_KINDS = void 0;
13
13
  exports.isConnectorKind = isConnectorKind;
14
14
  exports.isTransformLanguage = isTransformLanguage;
15
15
  exports.isWorkflowNodeKind = isWorkflowNodeKind;
16
16
  exports.isWorkflowStatus = isWorkflowStatus;
17
17
  exports.isWorkflowExecutionMode = isWorkflowExecutionMode;
18
+ exports.readWorkflowCallOutput = readWorkflowCallOutput;
18
19
  exports.validateWorkflow = validateWorkflow;
19
20
  exports.workflowRunOrder = workflowRunOrder;
20
21
  exports.workflowGraphHash = workflowGraphHash;
@@ -92,12 +93,13 @@ exports.TRANSFORM_RUNNER = Symbol('TRANSFORM_RUNNER');
92
93
  /**
93
94
  * What a node can be.
94
95
  *
95
- * Three kinds, and they are exactly the three verbs the existing connector
96
- * runner already performs in sequence: fetch, transform, publish. Nothing here
97
- * is a kind this service cannot execute, which is the same rule
98
- * {@link CONNECTOR_KINDS} follows — a kind that exists in the type and throws
99
- * at run time is worse than one that is absent, because the first looks
100
- * supported in a palette.
96
+ * The first three are exactly the three verbs the existing connector runner
97
+ * already performs in sequence: fetch, transform, publish. The fourth hands a
98
+ * position in the graph to a durable workflow that already exists in the
99
+ * deployment. Nothing here is a kind this service cannot execute, which is the
100
+ * same rule {@link CONNECTOR_KINDS} follows — a kind that exists in the type
101
+ * and throws at run time is worse than one that is absent, because the first
102
+ * looks supported in a palette.
101
103
  *
102
104
  * The kinds that were considered and rejected, since a small vocabulary is only
103
105
  * defensible if the omissions are:
@@ -115,6 +117,15 @@ exports.TRANSFORM_RUNNER = Symbol('TRANSFORM_RUNNER');
115
117
  * A `merge` kind would have had to carry a strategy field whose values the
116
118
  * runner would have to implement one by one, and an unimplemented strategy in
117
119
  * a dropdown is the failure this list exists to avoid.
120
+ * - **call a durable *step*** — the sibling of {@link WorkflowCallNode} that
121
+ * somebody will eventually come looking for, and it cannot be built. A
122
+ * durable step has no global identity: it is dispatched by a routing name
123
+ * that a worker subscribes to, and within a run it is addressed by its `seq`
124
+ * — a position in one workflow's history. There is no "run step X" entry
125
+ * point on the engine to call, no lifecycle of its own to await, and nothing
126
+ * to cancel. A workflow is the smallest thing that is addressable from
127
+ * outside a run, which is why `call` names one and not a step. If a step is
128
+ * what you want, the thing to call is a one-step workflow wrapping it.
118
129
  */
119
130
  exports.WORKFLOW_NODE_KINDS = [
120
131
  /** Reads records out of a system. The roots of the graph. */
@@ -123,6 +134,8 @@ exports.WORKFLOW_NODE_KINDS = [
123
134
  'transform',
124
135
  /** Writes into an object type and commits. Exactly one per workflow. */
125
136
  'sink',
137
+ /** Hands this position to an existing durable workflow. See {@link WorkflowCallNode}. */
138
+ 'call',
126
139
  ];
127
140
  /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
128
141
  function isWorkflowNodeKind(value) {
@@ -236,6 +249,56 @@ exports.WORKFLOW_EXECUTION_MODES = [
236
249
  function isWorkflowExecutionMode(value) {
237
250
  return exports.WORKFLOW_EXECUTION_MODES.some((mode) => mode === value);
238
251
  }
252
+ /**
253
+ * The number in {@link WorkflowCallEnvelope.contract}.
254
+ *
255
+ * A version on the *shape the catalog sends*, separate from the version of the
256
+ * workflow being called, because the two change for different reasons and a
257
+ * callee written against one has to be able to say which. A callee that reads
258
+ * this and does not recognise it should refuse rather than guess — a failed
259
+ * child is a failed node with a name attached, and a guess is a load nobody can
260
+ * account for.
261
+ */
262
+ exports.WORKFLOW_CALL_CONTRACT = 1;
263
+ /**
264
+ * Read a child's return value as staged rows — or as nothing, or refuse it.
265
+ *
266
+ * Three answers rather than two, because a call has two legitimate purposes and
267
+ * they must not be confused with a bug:
268
+ *
269
+ * - `undefined` — the child returned nothing this graph can read rows from. A
270
+ * perfectly ordinary outcome for a workflow called for its effect, and the
271
+ * node reports zero rows, out loud, in its logs. It is not silently treated
272
+ * as success-with-data: a full sink that then receives nothing refuses to
273
+ * commit an empty snapshot, which is the loud end of this path.
274
+ * - a {@link WorkflowCallOutput} — the child staged rows for this node.
275
+ * - a **throw** — the child answered with `batches`/`rowCount` that are not
276
+ * usable counts. Half a contract is a bug in the callee, and reading it as
277
+ * "no rows" would turn that bug into a load that quietly came out short.
278
+ *
279
+ * The catalog cannot check any of this before the graph runs; there is no
280
+ * schema for a workflow's output anywhere in the durable contract, and no way
281
+ * to reach one if there were. So the check is here, at the one moment the
282
+ * answer exists, and it names what it saw.
283
+ */
284
+ function readWorkflowCallOutput(value) {
285
+ if (typeof value !== 'object' || value === null)
286
+ return undefined;
287
+ const batches = Reflect.get(value, 'batches');
288
+ const rowCount = Reflect.get(value, 'rowCount');
289
+ if (batches === undefined && rowCount === undefined)
290
+ return undefined;
291
+ if (!isCount(batches) || !isCount(rowCount)) {
292
+ throw new Error(`It answered with batches=${describeCount(batches)} and rowCount=${describeCount(rowCount)}. A workflow that stages rows for a call node returns both as whole numbers of at least zero; returning one of them, or a value that is not a count, would leave this node to guess how much of the stage to read.`);
293
+ }
294
+ return { batches, rowCount };
295
+ }
296
+ function isCount(value) {
297
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
298
+ }
299
+ function describeCount(value) {
300
+ return value === undefined ? 'nothing' : JSON.stringify(value);
301
+ }
239
302
  /** Every way a graph can be refused. Exported so a canvas can key off the code. */
240
303
  exports.WORKFLOW_ISSUE_CODES = [
241
304
  'empty',
@@ -253,6 +316,7 @@ exports.WORKFLOW_ISSUE_CODES = [
253
316
  'unreachable',
254
317
  'dead-end',
255
318
  'transform-not-named',
319
+ 'call-not-named',
256
320
  ];
257
321
  /**
258
322
  * Everything that makes a graph unrunnable, in one pure function.
@@ -291,10 +355,10 @@ function validateWorkflow(graph) {
291
355
  if (issues.length > 0)
292
356
  return issues;
293
357
  const { outgoing, incoming } = buildAdjacency(nodes, edges);
294
- const sources = nodes.filter((node) => node.kind === 'source');
358
+ const originators = nodes.filter(originatesRows);
295
359
  const sinks = nodes.filter((node) => node.kind === 'sink');
296
360
  checkNodeWiring(nodes, incoming, outgoing, issues);
297
- checkEndpoints(sources, sinks, issues);
361
+ checkEndpoints(originators, sinks, issues);
298
362
  const looped = findCycle(nodes, incoming, outgoing);
299
363
  if (looped) {
300
364
  issues.push({
@@ -306,9 +370,27 @@ function validateWorkflow(graph) {
306
370
  // only unreachable *because* of the cycle, which points at the wrong boxes.
307
371
  return issues;
308
372
  }
309
- checkReachability(nodes, sources, sinks, incoming, outgoing, issues);
373
+ checkReachability(nodes, originators, sinks, incoming, outgoing, issues);
310
374
  return issues;
311
375
  }
376
+ /**
377
+ * Whether a node can produce rows without anything wired into it.
378
+ *
379
+ * A source obviously can. A **call** node can too, and this is the one rule the
380
+ * `call` kind changes rather than extends: the workflow it hands off to may
381
+ * itself read from a system, so a graph of `call → sink` is a real pipeline and
382
+ * refusing it for having "no source" would be false. What is not weakened is
383
+ * that a graph still needs *something* that originates rows and *something*
384
+ * that commits them — a graph of transforms alone is still refused.
385
+ *
386
+ * Every call node counts, not only the ones with no inbound edge, and that is
387
+ * the conservative direction: it makes this the root set for reachability too,
388
+ * so a mid-graph call node cannot make everything downstream of it look
389
+ * unreachable when its own upstream is fine.
390
+ */
391
+ function originatesRows(node) {
392
+ return node.kind === 'source' || node.kind === 'call';
393
+ }
312
394
  /**
313
395
  * Index the nodes by id, reporting the ids that cannot be used as one.
314
396
  *
@@ -403,15 +485,48 @@ function checkNodeWiring(nodes, incoming, outgoing, issues) {
403
485
  message: `Sink "${node.name}" (${node.id}) has an outbound edge. The sink commits the snapshot, so nothing can run after it.`,
404
486
  });
405
487
  }
406
- if (node.kind === 'transform' && node.transformId.length === 0) {
407
- issues.push({
408
- code: 'transform-not-named',
409
- nodeIds: [node.id],
410
- message: `Transform node "${node.name}" (${node.id}) names no transform, so there is no code for it to run.`,
411
- });
412
- }
488
+ const unconfigured = nodeIsUnconfigured(node);
489
+ if (unconfigured)
490
+ issues.push(unconfigured);
413
491
  }
414
492
  }
493
+ /**
494
+ * A node that names none of the thing it exists to run.
495
+ *
496
+ * The two kinds that point at something outside themselves — a transform at
497
+ * stored code, a call at a registered workflow — and both are reported the same
498
+ * way because they are the same mistake: a box on the canvas with nothing
499
+ * behind it, which looks finished and fails at run time.
500
+ */
501
+ function nodeIsUnconfigured(node) {
502
+ if (node.kind === 'transform' && node.transformId.length === 0) {
503
+ return {
504
+ code: 'transform-not-named',
505
+ nodeIds: [node.id],
506
+ message: `Transform node "${node.name}" (${node.id}) names no transform, so there is no code for it to run.`,
507
+ };
508
+ }
509
+ if (node.kind === 'call')
510
+ return callIsUnnamed(node);
511
+ return undefined;
512
+ }
513
+ /**
514
+ * A call that names half of what it needs, or nothing at all.
515
+ *
516
+ * Both halves, checked separately, because the version is the one people leave
517
+ * blank: a call that named only a workflow would run whichever version happens
518
+ * to be registered on the day the load runs, which is the single thing this
519
+ * node is built not to do.
520
+ */
521
+ function callIsUnnamed(node) {
522
+ if (node.callName.length > 0 && node.callVersion.length > 0)
523
+ return undefined;
524
+ return {
525
+ code: 'call-not-named',
526
+ nodeIds: [node.id],
527
+ message: `Call node "${node.name}" (${node.id}) does not name ${node.callName.length === 0 ? 'a workflow to call' : 'a version of the workflow it calls'}. A call pins a name and a version together — without the version it would run whichever one is registered when the load happens, and somebody else's deploy would change what this graph does.`,
528
+ };
529
+ }
415
530
  /**
416
531
  * That the graph has both ends, and that no two sinks claim the same type.
417
532
  *
@@ -424,12 +539,12 @@ function checkNodeWiring(nodes, incoming, outgoing, issues) {
424
539
  * model does not pretend otherwise — but two snapshots of one type in one run
425
540
  * leaves nothing to say which of them the readers should get.
426
541
  */
427
- function checkEndpoints(sources, sinks, issues) {
428
- if (sources.length === 0) {
542
+ function checkEndpoints(originators, sinks, issues) {
543
+ if (originators.length === 0) {
429
544
  issues.push({
430
545
  code: 'no-source',
431
546
  nodeIds: [],
432
- message: 'This workflow has no source node, so nothing would ever be read and the sink would commit an empty snapshot.',
547
+ message: 'This workflow has nothing that reads: no source node, and no call node handing off to a workflow that reads. Nothing would ever be fetched and the sink would commit an empty snapshot.',
433
548
  });
434
549
  }
435
550
  if (sinks.length === 0) {
@@ -512,12 +627,12 @@ function peelTails(leftover, outgoing) {
512
627
  }
513
628
  return leftover;
514
629
  }
515
- /** Nodes that no source reaches, and nodes that reach no sink. */
516
- function checkReachability(nodes, sources, sinks, incoming, outgoing, issues) {
517
- const reachableFromSources = walk(sources.map((node) => node.id), outgoing);
630
+ /** Nodes that nothing reading reaches, and nodes that reach no sink. */
631
+ function checkReachability(nodes, originators, sinks, incoming, outgoing, issues) {
632
+ const reachableFromSources = walk(originators.map((node) => node.id), outgoing);
518
633
  const reachesASink = walk(sinks.map((sink) => sink.id), incoming);
519
634
  for (const node of nodes) {
520
- if (sources.length > 0 && !reachableFromSources.has(node.id)) {
635
+ if (originators.length > 0 && !reachableFromSources.has(node.id)) {
521
636
  issues.push({
522
637
  code: 'unreachable',
523
638
  nodeIds: [node.id],
@@ -648,6 +763,22 @@ function canonicalNode(node) {
648
763
  // changed when it did not.
649
764
  return JSON.stringify([node.id, node.kind, node.transformId]);
650
765
  }
766
+ if (node.kind === 'call') {
767
+ // The called version IS in here, and that is the opposite choice from a
768
+ // transform above — for the reason the two differ. A transform's version is
769
+ // this catalog's own record of an edit somebody made here; a call's version
770
+ // is a different piece of code entirely. Repointing a node from `foo@1` to
771
+ // `foo@2` changes what the load does as surely as rewiring it does, so it
772
+ // is a new version of the graph and the run that used the old one stays
773
+ // identifiable.
774
+ return JSON.stringify([
775
+ node.id,
776
+ node.kind,
777
+ node.callName,
778
+ node.callVersion,
779
+ sortedEntries(node.config),
780
+ ]);
781
+ }
651
782
  return JSON.stringify([node.id, node.kind, node.targetType, node.mode ?? 'full']);
652
783
  }
653
784
  function sortedEntries(config) {
@@ -692,6 +823,18 @@ function isWorkflowNode(value) {
692
823
  if (kind === 'sink') {
693
824
  return typeof Reflect.get(value, 'targetType') === 'string';
694
825
  }
826
+ if (kind === 'call') {
827
+ // Both strings, and the config object, exactly as strictly as a source's:
828
+ // a stored call node missing its version is a node that would run whatever
829
+ // is registered today, which is the failure the pin exists to remove — and
830
+ // a graph that half-narrows is a load that runs nine nodes of ten.
831
+ const config = Reflect.get(value, 'config');
832
+ return (typeof Reflect.get(value, 'callName') === 'string' &&
833
+ typeof Reflect.get(value, 'callVersion') === 'string' &&
834
+ typeof config === 'object' &&
835
+ config !== null &&
836
+ !Array.isArray(config));
837
+ }
695
838
  const sourceKind = Reflect.get(value, 'sourceKind');
696
839
  const config = Reflect.get(value, 'config');
697
840
  return isConnectorKind(sourceKind) && typeof config === 'object' && config !== null;
@@ -716,7 +859,14 @@ function supportsWorkflows(store) {
716
859
  // save and not the transition would narrow cleanly here and then fail one
717
860
  // call later, in the middle of an apply that has already written types and
718
861
  // transforms into the target.
719
- typeof store.publishWorkflow === 'function');
862
+ typeof store.publishWorkflow === 'function' &&
863
+ // Asked for by name for the same reason `publishWorkflow` is, and it earned
864
+ // the place the hard way: a schedule authored on a graph is worthless if the
865
+ // store cannot hold one, and a predicate that narrowed without checking
866
+ // would let the schedule route resolve, accept a cron, and throw
867
+ // "saveWorkflowSchedule is not a function" at the person who typed it. This
868
+ // is the surface a scheduling incident already came through once.
869
+ typeof store.saveWorkflowSchedule === 'function');
720
870
  }
721
871
  /**
722
872
  * Whether this store keeps a transform's history.
@@ -21,5 +21,3 @@ export declare class QueryCache {
21
21
  clear(): void;
22
22
  get size(): number;
23
23
  }
24
- /** CSV, for the export button. */
25
- export declare function toCsv(result: CatalogQueryResult): string;
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.QueryCache = void 0;
4
- exports.toCsv = toCsv;
5
4
  const node_crypto_1 = require("node:crypto");
6
5
  /**
7
6
  * A small in-process cache for query results.
@@ -59,20 +58,3 @@ class QueryCache {
59
58
  }
60
59
  }
61
60
  exports.QueryCache = QueryCache;
62
- /** CSV, for the export button. */
63
- function toCsv(result) {
64
- const escapeCell = (value) => {
65
- if (value === null || value === undefined)
66
- return '';
67
- const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
68
- // Quote when the value could otherwise break the row apart. Doubling the
69
- // quote is the CSV escape, not a backslash.
70
- return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
71
- };
72
- const lines = [result.columns.map(escapeCell).join(',')];
73
- for (const row of result.rows) {
74
- lines.push(result.columns.map((column) => escapeCell(row[column])).join(','));
75
- }
76
- // CRLF: Excel still treats a bare LF file as one long row in some locales.
77
- return `${lines.join('\r\n')}\r\n`;
78
- }
@@ -52,6 +52,27 @@ export interface CatalogQueryRelation {
52
52
  type: string;
53
53
  }>;
54
54
  }
55
+ /**
56
+ * The same statement, asked for a row at a time.
57
+ *
58
+ * Two fields differ from {@link CatalogQueryRequest} and both differ because the
59
+ * caller is an export rather than a screen.
60
+ *
61
+ * `maxRows` is optional here and **absent means every row**. A table is a page
62
+ * of a result and a cap is what makes the page; an export is the whole thing by
63
+ * definition, and a capped export is a prefix presented as a file.
64
+ *
65
+ * `timeoutMs` is optional for the matching reason. A statement backing a screen
66
+ * has to answer while somebody waits; an export of a large table legitimately
67
+ * runs for minutes, and the bound that matters for it is that no stage holds
68
+ * more than a row — which is what the stream is for. An implementation given no
69
+ * timeout should impose none of its own.
70
+ */
71
+ export interface CatalogQueryStreamRequest {
72
+ sql: string;
73
+ maxRows?: number;
74
+ timeoutMs?: number;
75
+ }
55
76
  export interface CatalogQueryStore {
56
77
  /**
57
78
  * Run a read-only statement.
@@ -63,8 +84,30 @@ export interface CatalogQueryStore {
63
84
  runQuery(request: CatalogQueryRequest): Promise<CatalogQueryResult>;
64
85
  /** What a query may select from. */
65
86
  queryRelations(): Promise<CatalogQueryRelation[]>;
87
+ /**
88
+ * The same read, handing rows over as the engine produces them.
89
+ *
90
+ * **Optional, and the option is the store's to take rather than the caller's.**
91
+ * A store fronting an API, or one on a driver that buffers its result set
92
+ * before resolving, cannot offer this honestly, and a shim that collected the
93
+ * rows and yielded them back would satisfy the type while doing the exact
94
+ * thing the type exists to avoid. So an absent `streamQuery` is a real answer,
95
+ * and {@link CatalogService.streamSavedQuery} falls back to the capped
96
+ * buffered read for it — see the note there about what that costs.
97
+ *
98
+ * The contract on an implementation is one sentence: **do not read ahead of
99
+ * the consumer.** Whatever the driver offers must pause when the consumer
100
+ * stops pulling, all the way to the socket, or the memory has only moved.
101
+ *
102
+ * Returned synchronously — an async generator, not a promise for one — so that
103
+ * a consumer's `for await` owns the resource from the first pull, and an
104
+ * abandoned iteration runs the generator's `finally`.
105
+ */
106
+ streamQuery?(request: CatalogQueryStreamRequest): AsyncIterable<Record<string, unknown>>;
66
107
  }
67
108
  export declare function isQueryStore(store: unknown): store is CatalogQueryStore;
109
+ /** A query store that can hand rows over without materialising the result set. */
110
+ export declare function isStreamingQueryStore(store: unknown): store is CatalogQueryStore & Required<Pick<CatalogQueryStore, 'streamQuery'>>;
68
111
  /**
69
112
  * A cheap sanity check on the shape of a statement.
70
113
  *
@@ -8,12 +8,17 @@
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.isQueryStore = isQueryStore;
11
+ exports.isStreamingQueryStore = isStreamingQueryStore;
11
12
  exports.assertReadOnlyShape = assertReadOnlyShape;
12
13
  function isQueryStore(store) {
13
14
  return (typeof store === 'object' &&
14
15
  store !== null &&
15
16
  typeof Reflect.get(store, 'runQuery') === 'function');
16
17
  }
18
+ /** A query store that can hand rows over without materialising the result set. */
19
+ function isStreamingQueryStore(store) {
20
+ return isQueryStore(store) && typeof Reflect.get(store, 'streamQuery') === 'function';
21
+ }
17
22
  /**
18
23
  * A cheap sanity check on the shape of a statement.
19
24
  *
@@ -22,6 +22,7 @@ export declare class CatalogService {
22
22
  private readonly workspace?;
23
23
  constructor(registry: CatalogRegistry, store: CatalogReadStore, options: CatalogModuleOptions, workspace?: CatalogWorkspaceStore | undefined);
24
24
  private readonly cache;
25
+ private readonly logger;
25
26
  /** The whole model, as data. */
26
27
  getSnapshot(): CatalogSnapshot;
27
28
  getType(name: string): CatalogObjectTypeDef | undefined;
@@ -132,6 +133,41 @@ export declare class CatalogService {
132
133
  savedQuery: SavedQuery;
133
134
  result: CatalogQueryResult;
134
135
  }>;
136
+ /**
137
+ * The same saved query, as rows arriving rather than a result.
138
+ *
139
+ * For the export route, and it differs from {@link runSavedQuery} in three
140
+ * ways that are all the same decision seen from different sides.
141
+ *
142
+ * **No cap when the store streams.** An export is the whole result by
143
+ * definition — that is what distinguishes it from the table it was exported
144
+ * from — so `maxQueryRows` is not applied. It cannot be: a capped export is a
145
+ * prefix handed over as a complete file, with nothing in the file to say so.
146
+ *
147
+ * **No cache, in either direction.** Nothing is read from it, because what it
148
+ * holds is a *capped* page and serving that would silently truncate; and
149
+ * nothing is written to it, because the thing being produced is the object the
150
+ * cache exists to avoid holding.
151
+ *
152
+ * **No timeout.** {@link CatalogModuleOptions.queryTimeoutMs} bounds a
153
+ * statement somebody is waiting on behind a screen. An export of a large table
154
+ * runs for as long as the table takes, and the bound that matters is that
155
+ * neither this process nor the driver holds more than a row — which the stream
156
+ * is what provides. A client that gives up closes the connection, and the
157
+ * consumer stopping its pull is what releases the read.
158
+ *
159
+ * **A store that cannot stream falls back to the capped buffered read**, and
160
+ * that is a real difference in what the same route returns depending on what
161
+ * is mounted underneath. It is the honest option: lifting the cap on a store
162
+ * that materialises its result set would not make the export complete, it
163
+ * would move the failure into the driver, where there is no cap to report. The
164
+ * truncation is logged, since a CSV has nowhere to carry the fact.
165
+ */
166
+ streamSavedQuery(id: string): Promise<{
167
+ savedQuery: SavedQuery;
168
+ columns?: string[];
169
+ rows: AsyncIterable<Record<string, unknown>>;
170
+ }>;
135
171
  listDashboards(): Promise<Dashboard[]>;
136
172
  getDashboard(id: string): Promise<Dashboard>;
137
173
  /**
@@ -11,6 +11,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
11
11
  var __param = (this && this.__param) || function (paramIndex, decorator) {
12
12
  return function (target, key) { decorator(target, key, paramIndex); }
13
13
  };
14
+ var CatalogService_1;
14
15
  Object.defineProperty(exports, "__esModule", { value: true });
15
16
  exports.CatalogService = void 0;
16
17
  const common_1 = require("@nestjs/common");
@@ -25,6 +26,17 @@ const catalog_workspace_1 = require("./catalog.workspace");
25
26
  const search_1 = require("./search");
26
27
  const DEFAULT_PAGE_SIZE = 25;
27
28
  const DEFAULT_MAX_PAGE_SIZE = 200;
29
+ /**
30
+ * A finished array, seen as the shape a streamed read has.
31
+ *
32
+ * Yields out of the caller's array rather than copying it: the rows are already
33
+ * held by whoever produced them, and a second copy would be the cost this whole
34
+ * path exists to avoid, paid on the one code path that could least afford it.
35
+ */
36
+ async function* fromRows(rows) {
37
+ for (const row of rows)
38
+ yield row;
39
+ }
28
40
  /**
29
41
  * Reads objects of any catalogued type through one endpoint.
30
42
  *
@@ -34,7 +46,7 @@ const DEFAULT_MAX_PAGE_SIZE = 200;
34
46
  * new store cannot accidentally relax them — the appeal of a generic read
35
47
  * endpoint is also its whole risk.
36
48
  */
37
- let CatalogService = class CatalogService {
49
+ let CatalogService = CatalogService_1 = class CatalogService {
38
50
  registry;
39
51
  store;
40
52
  options;
@@ -46,6 +58,7 @@ let CatalogService = class CatalogService {
46
58
  this.workspace = workspace;
47
59
  }
48
60
  cache = new catalog_query_cache_1.QueryCache();
61
+ logger = new common_1.Logger(CatalogService_1.name);
49
62
  // ---------------------------------------------------------------------------
50
63
  // The facade.
51
64
  //
@@ -419,6 +432,63 @@ let CatalogService = class CatalogService {
419
432
  });
420
433
  return { savedQuery: saved, result };
421
434
  }
435
+ /**
436
+ * The same saved query, as rows arriving rather than a result.
437
+ *
438
+ * For the export route, and it differs from {@link runSavedQuery} in three
439
+ * ways that are all the same decision seen from different sides.
440
+ *
441
+ * **No cap when the store streams.** An export is the whole result by
442
+ * definition — that is what distinguishes it from the table it was exported
443
+ * from — so `maxQueryRows` is not applied. It cannot be: a capped export is a
444
+ * prefix handed over as a complete file, with nothing in the file to say so.
445
+ *
446
+ * **No cache, in either direction.** Nothing is read from it, because what it
447
+ * holds is a *capped* page and serving that would silently truncate; and
448
+ * nothing is written to it, because the thing being produced is the object the
449
+ * cache exists to avoid holding.
450
+ *
451
+ * **No timeout.** {@link CatalogModuleOptions.queryTimeoutMs} bounds a
452
+ * statement somebody is waiting on behind a screen. An export of a large table
453
+ * runs for as long as the table takes, and the bound that matters is that
454
+ * neither this process nor the driver holds more than a row — which the stream
455
+ * is what provides. A client that gives up closes the connection, and the
456
+ * consumer stopping its pull is what releases the read.
457
+ *
458
+ * **A store that cannot stream falls back to the capped buffered read**, and
459
+ * that is a real difference in what the same route returns depending on what
460
+ * is mounted underneath. It is the honest option: lifting the cap on a store
461
+ * that materialises its result set would not make the export complete, it
462
+ * would move the failure into the driver, where there is no cap to report. The
463
+ * truncation is logged, since a CSV has nowhere to carry the fact.
464
+ */
465
+ async streamSavedQuery(id) {
466
+ const saved = await this.getSavedQuery(id);
467
+ if (!(0, catalog_query_1.isQueryStore)(this.store)) {
468
+ throw new common_1.BadRequestException("This catalog's store does not support SQL queries.");
469
+ }
470
+ try {
471
+ (0, catalog_query_1.assertReadOnlyShape)(saved.sql);
472
+ }
473
+ catch (error) {
474
+ throw new common_1.BadRequestException(error instanceof Error ? error.message : String(error));
475
+ }
476
+ if ((0, catalog_query_1.isStreamingQueryStore)(this.store)) {
477
+ // Columns are left undefined: a streamed read learns them from its first
478
+ // row, exactly as the buffered one does from `rows[0]`.
479
+ return { savedQuery: saved, rows: this.store.streamQuery({ sql: saved.sql }) };
480
+ }
481
+ const cap = this.options.maxQueryRows ?? 1_000;
482
+ const result = await this.store.runQuery({
483
+ sql: saved.sql,
484
+ maxRows: cap,
485
+ timeoutMs: this.options.queryTimeoutMs ?? 15_000,
486
+ });
487
+ if (result.truncated) {
488
+ this.logger.warn(`Exported saved query ${saved.id} ("${saved.name}") was cut off at ${cap} rows: the mounted store cannot stream a result, so the export is bounded by maxQueryRows. The downloaded file is a prefix and says nothing about it.`);
489
+ }
490
+ return { savedQuery: saved, columns: result.columns, rows: fromRows(result.rows) };
491
+ }
422
492
  listDashboards() {
423
493
  return this.workspace ? this.workspace.listDashboards() : Promise.resolve([]);
424
494
  }
@@ -661,7 +731,7 @@ let CatalogService = class CatalogService {
661
731
  }
662
732
  };
663
733
  exports.CatalogService = CatalogService;
664
- exports.CatalogService = CatalogService = __decorate([
734
+ exports.CatalogService = CatalogService = CatalogService_1 = __decorate([
665
735
  (0, common_1.Injectable)(),
666
736
  __param(1, (0, common_1.Inject)(catalog_store_1.CATALOG_STORE)),
667
737
  __param(2, (0, common_1.Inject)(catalog_options_1.CATALOG_OPTIONS)),
@@ -407,30 +407,31 @@ export declare const CATALOG_RESERVED_COLUMNS: readonly ["_snapshot_id", "_princ
407
407
  export type CatalogReservedColumn = (typeof CATALOG_RESERVED_COLUMNS)[number];
408
408
  export declare function isReservedColumn(column: string): boolean;
409
409
  /**
410
- * Why a name cannot be written into SQL, in the words a publisher is given.
410
+ * The whole naming rule, which used to be written out here.
411
411
  *
412
- * One class for the whole ecosystem rather than one per adapter, so
413
- * `instanceof` is a usable question across packages. The publish-time check in
414
- * the pipeline package catches this to tell "that name cannot be an identifier"
415
- * from "something else failed inside the store", and with a class per adapter
416
- * that check would re-throw the moment the mounted store was not the one it
417
- * imported — turning a 400 that names the property into a 500 that names
418
- * nothing.
419
- */
420
- export declare class UnsafeIdentifierError extends Error {
421
- constructor(value: string);
422
- }
423
- /** Whether a name can be written into SQL as it stands. */
424
- export declare function isSafeIdentifier(value: string): boolean;
425
- /**
426
- * Refuse a name that cannot be a SQL identifier.
412
+ * `isSafeIdentifier` and friends, the {@link physicalColumn} cleaning and the
413
+ * {@link outputAlias} it feeds all moved to `catalog.identifiers.ts` — a file
414
+ * that imports nothing — and are re-exported here so that every caller that
415
+ * reached them from this module still does.
416
+ *
417
+ * The move was not tidying. This file imports `BadRequestException` at module
418
+ * scope, so a browser importing a *value* from it would pull NestJS into its
419
+ * bundle, and a console proposing to replicate a table has to be able to ask,
420
+ * before it draws anything, whether the source's column spellings could be
421
+ * published as property names. The question a publisher is refused on is
422
+ * `isSafeIdentifier(physicalColumn(name))`, so both halves had to become
423
+ * reachable from `/client`, answered by the same two functions the DDL runs
424
+ * rather than by a copy of either. See the docblock on `catalog.identifiers.ts`.
427
425
  *
428
- * Throws rather than answering, because the caller's next line writes the value
429
- * into a statement: a boolean that can be ignored is a boolean that eventually
430
- * is. {@link isSafeIdentifier} is there for the callers that are asking rather
431
- * than about to build.
426
+ * A bare `export … from` and not `import` + `export`, which is safe here for a
427
+ * reason worth writing down: nothing left in this file *calls* any of them.
428
+ * A re-export forwards a name without binding it locally, and while `outputAlias`
429
+ * still lived here — one line, calling `isSafeIdentifier` — that difference was
430
+ * a `ReferenceError` at the first read of any type, compiled and shipped by a
431
+ * rebase that had nothing to conflict on. Adding a caller here means turning
432
+ * this back into an import.
432
433
  */
433
- export declare function assertSafeIdentifier(value: string): void;
434
+ export { assertSafeIdentifier, isSafeIdentifier, outputAlias, physicalColumn, UnsafeIdentifierError, } from './catalog.identifiers';
434
435
  /** One property, and the column it cannot have. */
435
436
  export interface CatalogColumnCollision {
436
437
  /** `reserved` — it lands on a store column. `shared` — two properties collide. */