@dudousxd/nestjs-catalog 0.8.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.
@@ -14,7 +14,7 @@
14
14
  */
15
15
  export declare const CATALOG_LIB = "catalog";
16
16
  /** Every event name this package emits. Exported so a watcher can claim them. */
17
- export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed", "query.shared", "dashboard.shared"];
17
+ export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "overlay.reset", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed", "query.shared", "dashboard.shared"];
18
18
  export type CatalogEvent = (typeof CATALOG_EVENTS)[number];
19
19
  /**
20
20
  * Where each event sits in the life of one load.
@@ -81,6 +81,77 @@ export interface CatalogEventPayloads {
81
81
  property?: string;
82
82
  changed: string[];
83
83
  };
84
+ /**
85
+ * The whole overlay was discarded — every curated label, description, unit,
86
+ * order, hidden flag and classification in the catalog, in one request.
87
+ *
88
+ * An event of its own rather than a `type.curated` with the name left off.
89
+ * That payload leads with `typeName`, and a recorder lifts it into an indexed
90
+ * column; a reset has no single type, so it would land as a curation edit
91
+ * belonging to no type, indistinguishable from a malformed one. It is also a
92
+ * different act: `type.curated` records a decision about one column, this
93
+ * records the destruction of every such decision. Without it the trail could
94
+ * say who renamed one column and not who reverted every name at once — and
95
+ * both are `catalog:curate`, so the same curator can do either.
96
+ *
97
+ * **The classifications are why this is not merely tidy.** A classification is
98
+ * what `visibleToPrincipal` filters search results on, so a reset silently
99
+ * re-admits every classified property's *name* to searches by principals who
100
+ * could not see it an instant earlier. That is a change in who can see what,
101
+ * made by a route the controller documents as presentation-only.
102
+ *
103
+ * WHAT IT CARRIES, AND WHAT IT DELIBERATELY DOES NOT
104
+ * --------------------------------------------------
105
+ * The overlay is discarded rather than versioned, so nothing can be looked up
106
+ * afterwards: what is not in this payload is nowhere. That argues for carrying
107
+ * all of it, and all of it is the wrong answer — an audit row holding a
108
+ * verbatim copy of the overlay is a backup, and a backup nobody designed: no
109
+ * restore path, no retention policy of its own, and a JSON column that grows
110
+ * with the catalog. It would be read as one, too. The first person who needed
111
+ * it would find it, and the second would rely on it.
112
+ *
113
+ * So: a summary, drawn where the reader's question stops being "what did I
114
+ * lose" and starts being "give it back".
115
+ *
116
+ * - {@link typeNames}, because "somebody reset the catalog" is nearly useless
117
+ * six months later and "was the work on `Dispute` in it" is what is actually
118
+ * asked. Bounded by how many types anyone had curated.
119
+ * - {@link properties}, the scale of it as one number. The property *names* are
120
+ * where a summary would turn into the dump.
121
+ * - {@link classifications} in full, values included, despite that line. They
122
+ * are the one part of the overlay whose loss changes what the catalog shows
123
+ * to whom, they are a small subset of it, and re-typing them is the only
124
+ * recovery anybody can perform.
125
+ *
126
+ * **No `principalId`, and the absence is a limit rather than a decision that
127
+ * the actor does not matter.** `resetOverlay()` takes no principal, the route
128
+ * that calls it resolves none, and `RoutingCatalogRegistry` forwards the call
129
+ * by hand — so a field here would be `undefined` on every row, and an audit
130
+ * table lifts `principalId` into a column where empty reads as "nobody did
131
+ * this" rather than "this was not captured". `type.curated` has the same gap.
132
+ * Closing it means threading a principal through the controller, the service
133
+ * and every registry, which is a change to those, not a field on this payload.
134
+ *
135
+ * Emitted even when the overlay was empty, with zeroes. A trail that recorded
136
+ * only destructive resets cannot tell "nobody pressed it" from "somebody
137
+ * pressed it and nothing was there", and the second is worth seeing.
138
+ */
139
+ 'overlay.reset': {
140
+ /** Every type that carried curation, so the trail names what was lost. */
141
+ typeNames: string[];
142
+ /** How many per-property entries went with them, across every type. */
143
+ properties: number;
144
+ /**
145
+ * Every classification that stopped applying, with its value — because that
146
+ * is what somebody restoring one needs, and after the reset there is nowhere
147
+ * left to read it.
148
+ */
149
+ classifications: Array<{
150
+ typeName: string;
151
+ property: string;
152
+ classification: string;
153
+ }>;
154
+ };
84
155
  /** A connector began pulling. */
85
156
  'connector.run.started': {
86
157
  connectorId: string;
@@ -27,6 +27,7 @@ exports.CATALOG_EVENTS = [
27
27
  'snapshot.committed',
28
28
  'snapshot.dropped',
29
29
  'type.curated',
30
+ 'overlay.reset',
30
31
  'connector.run.started',
31
32
  'connector.run.finished',
32
33
  'transform.changed',
@@ -67,6 +68,11 @@ exports.CATALOG_EVENT_PHASE = {
67
68
  // be complete is exactly the mechanism that will fail the build the day a new
68
69
  // event is added and nobody thinks about where it belongs.
69
70
  'type.curated': 2,
71
+ // The other curation event, ranked with the one it undoes. It carries no
72
+ // snapshot id either — a reset is not part of any load — so like the rank
73
+ // above this is never consulted, and it is written out because the `Record`
74
+ // has to be complete.
75
+ 'overlay.reset': 2,
70
76
  // Sharing carries no snapshot id either, for the same reason curation does
71
77
  // not: it is a standalone act on a saved query or a board, not a step of any
72
78
  // load. So these ranks are never consulted, and they are written out for the
@@ -17,7 +17,28 @@ export declare class FileCatalogOverlayStore implements CatalogOverlayStore {
17
17
  load(): Promise<CatalogOverlay>;
18
18
  save(overlay: CatalogOverlay): Promise<void>;
19
19
  }
20
- /** For tests, and for deployments that want the catalog strictly read-only. */
20
+ /**
21
+ * For tests, and for a single-process deployment content to lose every curated
22
+ * value when it restarts.
23
+ *
24
+ * **Not a read-only mode**, which is what this used to offer itself as. Nothing
25
+ * here refuses anything: `save` takes the overlay and keeps it, `PATCH
26
+ * /catalog/types/:name` answers 200 and emits `type.curated`, and the rename is
27
+ * real right up until the process ends. What it is not is *shared* — the overlay
28
+ * lives in one process's heap, so two replicas behind the same load balancer
29
+ * disagree about what a column is called, and which name a curator sees back
30
+ * depends on which pod took their request. A deployment that chose this store
31
+ * because the docblock promised read-only got precisely the writes it was trying
32
+ * to prevent, and got them inconsistently.
33
+ *
34
+ * There is no mode here to turn on instead, because read-only is not a store's
35
+ * decision. The writes arrive on routes that declare `catalog:curate`, and
36
+ * whether a deployment grants that scope is its guard's business — see the
37
+ * declare-and-enforce split in `catalog.route-auth.ts`. A store that threw would
38
+ * turn a policy answer ("you may not curate here") into a 500 from a route the
39
+ * library documents as working, and would put a second mechanism beside the one
40
+ * that already decides.
41
+ */
21
42
  export declare class InMemoryCatalogOverlayStore implements CatalogOverlayStore {
22
43
  private overlay;
23
44
  load(): Promise<CatalogOverlay>;
@@ -13,12 +13,15 @@ class FileCatalogOverlayStore {
13
13
  try {
14
14
  const raw = await (0, promises_1.readFile)(this.path, 'utf8');
15
15
  const parsed = JSON.parse(raw);
16
- if (parsed &&
17
- typeof parsed === 'object' &&
18
- 'types' in parsed &&
19
- typeof parsed.types === 'object') {
16
+ // Narrowed by a guard rather than asserted. The file on disk is edited by
17
+ // hand that is the whole point of a JSON overlay — so its contents are
18
+ // exactly as trustworthy as whoever last opened it, and an assertion here
19
+ // would hand the registry a shape it promised to have rather than one it
20
+ // was checked for. The failure that buys is quiet: `types` arriving as
21
+ // `null` (an object, by `typeof`) or as an array reads as an overlay with
22
+ // no curation, and every label somebody wrote silently stops applying.
23
+ if (isOverlay(parsed))
20
24
  return parsed;
21
- }
22
25
  return { types: {} };
23
26
  }
24
27
  catch {
@@ -31,7 +34,28 @@ class FileCatalogOverlayStore {
31
34
  }
32
35
  }
33
36
  exports.FileCatalogOverlayStore = FileCatalogOverlayStore;
34
- /** For tests, and for deployments that want the catalog strictly read-only. */
37
+ /**
38
+ * For tests, and for a single-process deployment content to lose every curated
39
+ * value when it restarts.
40
+ *
41
+ * **Not a read-only mode**, which is what this used to offer itself as. Nothing
42
+ * here refuses anything: `save` takes the overlay and keeps it, `PATCH
43
+ * /catalog/types/:name` answers 200 and emits `type.curated`, and the rename is
44
+ * real right up until the process ends. What it is not is *shared* — the overlay
45
+ * lives in one process's heap, so two replicas behind the same load balancer
46
+ * disagree about what a column is called, and which name a curator sees back
47
+ * depends on which pod took their request. A deployment that chose this store
48
+ * because the docblock promised read-only got precisely the writes it was trying
49
+ * to prevent, and got them inconsistently.
50
+ *
51
+ * There is no mode here to turn on instead, because read-only is not a store's
52
+ * decision. The writes arrive on routes that declare `catalog:curate`, and
53
+ * whether a deployment grants that scope is its guard's business — see the
54
+ * declare-and-enforce split in `catalog.route-auth.ts`. A store that threw would
55
+ * turn a policy answer ("you may not curate here") into a 500 from a route the
56
+ * library documents as working, and would put a second mechanism beside the one
57
+ * that already decides.
58
+ */
35
59
  class InMemoryCatalogOverlayStore {
36
60
  overlay = { types: {} };
37
61
  async load() {
@@ -42,3 +66,20 @@ class InMemoryCatalogOverlayStore {
42
66
  }
43
67
  }
44
68
  exports.InMemoryCatalogOverlayStore = InMemoryCatalogOverlayStore;
69
+ /**
70
+ * Whether a parsed file is an overlay, checked to the depth that matters.
71
+ *
72
+ * The nesting below `types` is deliberately NOT walked. Every consumer reads it
73
+ * defensively — an entry that is missing, or missing the key it wanted, is the
74
+ * ordinary case for a type nobody has curated — so validating each entry would
75
+ * be re-implementing a tolerance the readers already have, and refusing the
76
+ * whole file over one malformed entry would discard every good one beside it.
77
+ */
78
+ function isOverlay(value) {
79
+ if (!value || typeof value !== 'object')
80
+ return false;
81
+ const types = Reflect.get(value, 'types');
82
+ // Not `typeof types === 'object'`, which admits `null` and arrays. Both parse
83
+ // from a hand-edited file, and both read downstream as "nothing is curated".
84
+ return Boolean(types) && typeof types === 'object' && !Array.isArray(types);
85
+ }
@@ -158,7 +158,28 @@ export interface CatalogTransform {
158
158
  }
159
159
  export interface TransformResult {
160
160
  rows: Array<Record<string, unknown>>;
161
- /** Anything the code logged. Surfaced in the run, never in the rows. */
161
+ /**
162
+ * Anything the code logged. Surfaced in the run, never in the rows.
163
+ *
164
+ * "Anything the code logged" is meant literally, and in whichever language the
165
+ * transform is written: `console.log` and its siblings in JavaScript and
166
+ * TypeScript, `print` and anything written to `sys.stderr` in Python. A
167
+ * transform author's first instinct for finding out what their code is doing
168
+ * has to be the thing that works, because the alternative — an empty panel and
169
+ * no explanation — reads as "my code never ran" rather than as "you used the
170
+ * wrong function".
171
+ *
172
+ * In call order, with the channels interleaved rather than separated: a reader
173
+ * is reconstructing a sequence, and two lists cannot be zipped back together.
174
+ *
175
+ * **Bounded, by the runner, before it is returned.** These are lines user code
176
+ * chose and they cross a durable step boundary into the run record, so an
177
+ * unbounded capture would make the size of a `finishRun` write a property of
178
+ * somebody's source data. The bundled runner keeps the first 500 lines at
179
+ * 2,000 characters each and appends a line saying how many it dropped — a
180
+ * truncation nobody is told about is the same failure as a log nobody is told
181
+ * about. Consumers cap again for display, more tightly.
182
+ */
162
183
  logs: string[];
163
184
  elapsedMs: number;
164
185
  }
@@ -62,5 +62,25 @@ export declare abstract class CatalogRegistry {
62
62
  /** Presentation-only edits. Never a schema change. */
63
63
  abstract patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>): Promise<CatalogObjectTypeDef | undefined>;
64
64
  abstract patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string]): Promise<CatalogObjectTypeDef | undefined>;
65
+ /**
66
+ * Discard every tier-0 edit at once.
67
+ *
68
+ * **An implementation that really discards must emit `overlay.reset`**, and
69
+ * the reason is an asymmetry this class would otherwise have: the two patches
70
+ * above are audited one field at a time, so a trail could say who renamed one
71
+ * column and not who reverted every name in the catalog — while both need only
72
+ * `catalog:curate`. The summary has to be built before the write; nothing
73
+ * versions an overlay, so afterwards there is nothing left to read.
74
+ *
75
+ * **Refusing is an implementation too, and refusing emits nothing.** A
76
+ * registry whose curated values have no derived layer underneath them has
77
+ * nothing to fall back to, so a reset there is destruction rather than a
78
+ * revert, and the throw is the whole answer — no act, no record of one.
79
+ * `StoredCatalogRegistry` is that case.
80
+ *
81
+ * Which of those a deployment runs is why the event is worth more than the
82
+ * call it accompanies: a registry that quietly resets without emitting looks
83
+ * exactly like one that never ran a reset at all.
84
+ */
65
85
  abstract resetOverlay(): Promise<void>;
66
86
  }
@@ -35,6 +35,17 @@ export declare class MikroOrmCatalogRegistry extends CatalogRegistry implements
35
35
  patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>): Promise<CatalogObjectTypeDef | undefined>;
36
36
  /** Tier-0 edit on a property. Never touches the database. */
37
37
  patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string]): Promise<CatalogObjectTypeDef | undefined>;
38
+ /**
39
+ * Drop every tier-0 edit, and leave a record that it happened.
40
+ *
41
+ * The summary is taken before the overlay is cleared because it is the only
42
+ * record there will ever be: nothing versions an overlay, so the discarded
43
+ * values are gone the instant the store is written. See `overlay.reset` in
44
+ * `catalog.events.ts` for why the payload is a summary and not a copy.
45
+ *
46
+ * Emitted after the write, like the two patches above, so the trail says what
47
+ * happened rather than what was about to.
48
+ */
38
49
  resetOverlay(): Promise<void>;
39
50
  private persist;
40
51
  private rebuild;
@@ -221,9 +221,22 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
221
221
  });
222
222
  return this.getType(type.name);
223
223
  }
224
+ /**
225
+ * Drop every tier-0 edit, and leave a record that it happened.
226
+ *
227
+ * The summary is taken before the overlay is cleared because it is the only
228
+ * record there will ever be: nothing versions an overlay, so the discarded
229
+ * values are gone the instant the store is written. See `overlay.reset` in
230
+ * `catalog.events.ts` for why the payload is a summary and not a copy.
231
+ *
232
+ * Emitted after the write, like the two patches above, so the trail says what
233
+ * happened rather than what was about to.
234
+ */
224
235
  async resetOverlay() {
236
+ const discarded = summariseOverlay(this.overlay);
225
237
  this.overlay = { types: {} };
226
238
  await this.persist();
239
+ (0, catalog_events_1.emitCatalog)('overlay.reset', discarded);
227
240
  }
228
241
  async persist() {
229
242
  await this.overlayStore.save(this.overlay);
@@ -368,6 +381,39 @@ exports.MikroOrmCatalogRegistry = MikroOrmCatalogRegistry = MikroOrmCatalogRegis
368
381
  __param(2, (0, common_1.Inject)(catalog_overlay_store_token_1.CATALOG_OVERLAY_STORE)),
369
382
  __metadata("design:paramtypes", [core_1.MikroORM, Object, Object])
370
383
  ], MikroOrmCatalogRegistry);
384
+ /**
385
+ * What a reset is about to destroy, in the shape the trail keeps it.
386
+ *
387
+ * Here rather than in `catalog.events.ts` because it reads a `CatalogOverlay`,
388
+ * and this is the only registry that has one — the payload type is the contract,
389
+ * this is one producer of it. Pure and taking the overlay as an argument so the
390
+ * order is forced: a caller has to hold the old overlay to call it, and cannot
391
+ * accidentally summarise the empty one it just installed.
392
+ *
393
+ * A type entry counts whatever it holds, including an entry that ended up empty.
394
+ * `buildType` treats a present entry as enrichment on the same terms, and the
395
+ * honest reading of one is "somebody patched this type" — which is exactly what
396
+ * the reset undid.
397
+ */
398
+ function summariseOverlay(overlay) {
399
+ const typeNames = Object.keys(overlay.types);
400
+ const classifications = [];
401
+ let properties = 0;
402
+ for (const typeName of typeNames) {
403
+ const patched = overlay.types[typeName]?.properties ?? {};
404
+ for (const [property, patch] of Object.entries(patched)) {
405
+ properties += 1;
406
+ const { classification } = patch;
407
+ // Only a classification that was actually set. An entry that merely
408
+ // renamed the column carries the key as `undefined`, and listing it would
409
+ // report a classification lost that nobody had applied.
410
+ if (classification !== undefined) {
411
+ classifications.push({ typeName, property, classification });
412
+ }
413
+ }
414
+ }
415
+ return { typeNames, properties, classifications };
416
+ }
371
417
  /**
372
418
  * How one field is presented, resolved across the tiers.
373
419
  *
@@ -17,6 +17,57 @@ const node_path_1 = require("node:path");
17
17
  const common_1 = require("@nestjs/common");
18
18
  const DEFAULT_TIMEOUT_MS = 30_000;
19
19
  const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
20
+ /**
21
+ * How much of what a transform logged is carried back, on both axes.
22
+ *
23
+ * Both, because either one alone leaves the capture unbounded in the dimension
24
+ * it does not cover, and this capture is *user code writing whatever it likes*.
25
+ * A transform that logs one line per record — the most natural debugging move
26
+ * there is — puts a copy of the source's data into `logs`, and `logs` is the one
27
+ * thing that crosses a durable step boundary and lands in the run record. So the
28
+ * ceiling is fixed here, in the child, before any of it is serialised: the
29
+ * alternative is a `finishRun` write whose size is a property of somebody's
30
+ * data.
31
+ *
32
+ * The same two numbers for JavaScript and for Python, applied by the two
33
+ * harnesses below in the same order. A transform's log behaviour changing
34
+ * because of the language it happens to be written in is a difference nobody can
35
+ * predict from reading either one.
36
+ *
37
+ * Deliberately far above what anything downstream keeps — the connector runner
38
+ * takes fifty lines, the workflow runner twenty per node at four hundred
39
+ * characters — because this is the *safety* bound and those are the *display*
40
+ * bounds. A harness that truncated at the display limit would decide, in the
41
+ * child, what a future consumer is allowed to see.
42
+ *
43
+ * What is dropped is said out loud, in a final line, rather than dropped
44
+ * quietly. Silence about a missing log is the exact failure this whole capture
45
+ * exists to remove; reproducing it at line 501 would only move it.
46
+ */
47
+ const MAX_LOG_LINES = 500;
48
+ const MAX_LOG_LINE_CHARS = 2_000;
49
+ /**
50
+ * How much of what a *failing* transform logged is folded into the error.
51
+ *
52
+ * A failure throws, and a throw carries a message and nothing else — so the
53
+ * `logs` of a run that raised never reach the caller at all, and every consumer
54
+ * records the traceback with none of the output that led to it. Capturing
55
+ * `print` and then discarding it at the exact moment it is most wanted would be
56
+ * a fix that stops one step short of the case it was written for.
57
+ *
58
+ * The **last** lines, not the first, which is the opposite of what the display
59
+ * caps downstream do — and deliberately. Those are trimming a successful run's
60
+ * narrative, where the beginning is the story; this is the approach to a
61
+ * traceback, where the last thing printed is the one that says where the code
62
+ * got to.
63
+ *
64
+ * Small on both axes because this lands in an error message, and an error
65
+ * message ends up in a run row, a log line and a console toast. The full set is
66
+ * still on the result whenever the transform returned at all; this is the
67
+ * consolation for the path where there is no result.
68
+ */
69
+ const FAILURE_LOG_LINES = 10;
70
+ const FAILURE_LOG_CHARS = 200;
20
71
  /** Packages worth telling the author about, if the environment has them. */
21
72
  const REPORTED_PACKAGES = ['pandas', 'numpy', 'pyarrow', 'requests'];
22
73
  /**
@@ -111,14 +162,15 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
111
162
  catch {
112
163
  throw new Error(`The transform did not return anything readable. stderr: ${stderr.slice(0, 500)}`);
113
164
  }
165
+ const logs = Array.isArray(parsed.logs) ? parsed.logs.map(String) : [];
114
166
  if (parsed.error)
115
- throw new Error(parsed.error);
167
+ throw new Error(withFinalLogs(parsed.error, logs));
116
168
  if (!Array.isArray(parsed.rows)) {
117
169
  throw new Error('The transform must return an array of rows. Returning anything else would leave the load ambiguous.');
118
170
  }
119
171
  return {
120
172
  rows: parsed.rows.filter((row) => typeof row === 'object' && row !== null && !Array.isArray(row)),
121
- logs: Array.isArray(parsed.logs) ? parsed.logs.map(String) : [],
173
+ logs,
122
174
  elapsedMs: Date.now() - started,
123
175
  };
124
176
  }
@@ -204,18 +256,68 @@ exports.SubprocessTransformRunner = SubprocessTransformRunner = SubprocessTransf
204
256
  (0, common_1.Injectable)(),
205
257
  __metadata("design:paramtypes", [Object])
206
258
  ], SubprocessTransformRunner);
259
+ /**
260
+ * The traceback, plus the tail of what the code printed on its way to it.
261
+ *
262
+ * Named in the message rather than appended bare, and counted rather than
263
+ * merely truncated: "the last 10 of 57 lines" tells a reader there is more to
264
+ * find on the run's own log, where a silent tail would let them believe they
265
+ * were looking at everything the transform said.
266
+ */
267
+ function withFinalLogs(error, logs) {
268
+ if (logs.length === 0)
269
+ return error;
270
+ const tail = logs
271
+ .slice(-FAILURE_LOG_LINES)
272
+ .map((line) => line.length > FAILURE_LOG_CHARS ? `${line.slice(0, FAILURE_LOG_CHARS)}…` : line);
273
+ const heading = logs.length > tail.length
274
+ ? `The last ${tail.length} of ${logs.length} lines it logged first:`
275
+ : `${tail.length === 1 ? 'The line' : `The ${tail.length} lines`} it logged first:`;
276
+ return `${error}\n${heading}\n${tail.map((line) => ` ${line}`).join('\n')}`;
277
+ }
207
278
  /**
208
279
  * The JavaScript and TypeScript harness.
209
280
  *
210
281
  * `console.log` is captured rather than left on stdout so user code cannot
211
282
  * corrupt the single JSON line this prints — a transform that logs a `{` would
212
283
  * otherwise break its own result parsing, which is a maddening thing to debug.
284
+ *
285
+ * Every console channel that reaches a terminal is overridden, not just the four
286
+ * that were here first. `console.debug` writes to stdout exactly as `console.log`
287
+ * does, so leaving it alone left one spelling of "log something" that silently
288
+ * corrupted the result line; `console.trace` writes to stderr, so leaving it
289
+ * alone left one spelling that silently went nowhere. Both are the same mistake
290
+ * the Python harness made with `print`, and there is no reading of "anything the
291
+ * code logged" under which they are not it.
292
+ *
293
+ * The channels share one array and keep call order, which is the only ordering
294
+ * that answers the question logs are read for — what happened, and in what
295
+ * sequence. Nothing marks which channel a line came from: a reader looking at a
296
+ * failed run wants the sequence, and splitting it into two lists would make the
297
+ * interleaving unrecoverable to buy a label the line's own text usually carries.
298
+ *
299
+ * Bounded by {@link MAX_LOG_LINES} and {@link MAX_LOG_LINE_CHARS}, applied here
300
+ * rather than after the fact, so a transform that logs a copy of its input never
301
+ * gets as far as being serialised.
213
302
  */
214
303
  function javascriptHarness(code) {
215
304
  return `
216
305
  const logs = [];
217
- const write = (...args) => logs.push(args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" "));
306
+ let dropped = 0;
307
+ const keep = (line) => {
308
+ if (logs.length >= ${MAX_LOG_LINES}) { dropped += 1; return; }
309
+ logs.push(
310
+ line.length > ${MAX_LOG_LINE_CHARS}
311
+ ? line.slice(0, ${MAX_LOG_LINE_CHARS}) + "… (" + (line.length - ${MAX_LOG_LINE_CHARS}) + " more characters)"
312
+ : line,
313
+ );
314
+ };
315
+ const write = (...args) => keep(args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" "));
218
316
  console.log = write; console.info = write; console.warn = write; console.error = write;
317
+ console.debug = write; console.trace = write;
318
+ const captured = () => dropped === 0
319
+ ? logs
320
+ : logs.concat(["… " + dropped + " more line(s) were logged and dropped: a transform keeps its first ${MAX_LOG_LINES}."]);
219
321
 
220
322
  let input = "";
221
323
  process.stdin.setEncoding("utf8");
@@ -225,11 +327,11 @@ try {
225
327
  const records = JSON.parse(input || "[]");
226
328
  const transform = async (records) => { ${code} };
227
329
  const rows = await transform(records);
228
- process.stdout.write(JSON.stringify({ rows: rows ?? [], logs }));
330
+ process.stdout.write(JSON.stringify({ rows: rows ?? [], logs: captured() }));
229
331
  } catch (error) {
230
332
  process.stdout.write(JSON.stringify({
231
333
  error: error instanceof Error ? \`\${error.name}: \${error.message}\` : String(error),
232
- logs,
334
+ logs: captured(),
233
335
  }));
234
336
  }
235
337
  `;
@@ -241,6 +343,44 @@ try {
241
343
  * that reaches for pandas will naturally end with one — making it write
242
344
  * `.to_dict("records")` would be a papercut on the only path pandas is worth
243
345
  * importing for.
346
+ *
347
+ * **`print` is redirected, for the same reason `console.log` is.** It used to go
348
+ * straight through to the child's real stdout, where the last-line result parse
349
+ * discarded it — so the single most obvious thing a person writes while working
350
+ * out what their transform is doing produced an empty log panel and no
351
+ * explanation. That is not a missing nicety: it costs the author their trust in
352
+ * the runner before they have written anything real, and the conclusion it
353
+ * invites ("my code never ran") is the wrong one. `log()` still exists, because
354
+ * transforms in the wild call it and a `NameError` is a worse answer than a
355
+ * redundant helper, but it is now literally `print` — one buffer, one ordering,
356
+ * and nothing that only works if you already knew about it.
357
+ *
358
+ * **stderr is captured too**, into the same list and in call order. `warnings`,
359
+ * a `logging` handler at its default configuration, and a traceback the code
360
+ * printed itself all land there, and those are precisely the lines somebody is
361
+ * looking for when a transform misbehaves. It is not marked as stderr, matching
362
+ * the JavaScript harness, which does not distinguish `console.error` either: the
363
+ * sequence is what a reader is reconstructing, and two lists would make the
364
+ * interleaving unrecoverable.
365
+ *
366
+ * What was written **before** an exception survives it. The redirect is a
367
+ * context manager around the call rather than a swap held for the whole script,
368
+ * so it unwinds on the way out of a traceback with the buffer intact, and the
369
+ * error branch reports the same lines the success branch would have. A
370
+ * transform that printed three things and then divided by zero is the case logs
371
+ * matter most for, and it is the case a naive swap loses.
372
+ *
373
+ * Bounded by {@link MAX_LOG_LINES} and {@link MAX_LOG_LINE_CHARS}, the same two
374
+ * numbers the JavaScript harness applies. Note that this bounds the *sink*, not
375
+ * only the result: an unterminated write longer than a line's ceiling is flushed
376
+ * as its own line rather than accumulated, so a transform writing without
377
+ * newlines cannot grow the child's memory either.
378
+ *
379
+ * The limit worth stating: this redirects Python-level writes to `sys.stdout`
380
+ * and `sys.stderr`. Output from a C extension or a subprocess that writes to the
381
+ * file descriptors underneath goes to the real streams, exactly as it does past
382
+ * an overridden `console` in Node. Redirecting the descriptors themselves would
383
+ * take the result channel with it.
244
384
  */
245
385
  function pythonHarness(code) {
246
386
  const indented = code
@@ -248,11 +388,68 @@ function pythonHarness(code) {
248
388
  .map((line) => ` ${line}`)
249
389
  .join('\n');
250
390
  return `
251
- import sys, json
391
+ import sys, json, contextlib
252
392
 
253
393
  logs = []
394
+ # A one-element list rather than a module global reassigned inside the helper,
395
+ # so the counter needs no \`global\` statement in generated code.
396
+ dropped = [0]
397
+
398
+ def keep(line):
399
+ if len(logs) >= ${MAX_LOG_LINES}:
400
+ dropped[0] += 1
401
+ return
402
+ if len(line) > ${MAX_LOG_LINE_CHARS}:
403
+ line = "{}… ({} more characters)".format(
404
+ line[:${MAX_LOG_LINE_CHARS}], len(line) - ${MAX_LOG_LINE_CHARS}
405
+ )
406
+ logs.append(line)
407
+
408
+ class Sink:
409
+ """Stands in for stdout and stderr while the transform runs.
410
+
411
+ Line-buffered by hand because \`print("a", "b")\` arrives as four separate
412
+ writes — the parts, the separators and the terminator — and appending each
413
+ one as its own entry would shred every multi-argument call.
414
+ """
415
+
416
+ def __init__(self):
417
+ self.partial = ""
418
+
419
+ def write(self, text):
420
+ if not isinstance(text, str):
421
+ text = str(text)
422
+ self.partial += text
423
+ while "\\n" in self.partial:
424
+ line, self.partial = self.partial.split("\\n", 1)
425
+ keep(line)
426
+ # A write with no newline in it is still bounded: past a line's ceiling
427
+ # there is nothing more to keep, so it is emitted rather than held.
428
+ if len(self.partial) > ${MAX_LOG_LINE_CHARS}:
429
+ keep(self.partial)
430
+ self.partial = ""
431
+ return len(text)
432
+
433
+ def writelines(self, lines):
434
+ for line in lines:
435
+ self.write(line)
436
+
437
+ def flush(self):
438
+ pass
439
+
440
+ def isatty(self):
441
+ return False
442
+
443
+ def drain(self):
444
+ """Whatever was written without a trailing newline is still output."""
445
+ if self.partial:
446
+ keep(self.partial)
447
+ self.partial = ""
448
+
449
+ sink = Sink()
450
+
254
451
  def log(*args):
255
- logs.append(" ".join(str(a) for a in args))
452
+ print(*args)
256
453
 
257
454
  def transform(records):
258
455
  ${indented || ' return records'}
@@ -266,15 +463,31 @@ def to_rows(result):
266
463
  return result.to_dict("records")
267
464
  return result
268
465
 
466
+ def captured():
467
+ sink.drain()
468
+ if dropped[0] == 0:
469
+ return logs
470
+ return logs + [
471
+ "… {} more line(s) were logged and dropped: a transform keeps its first {}.".format(
472
+ dropped[0], ${MAX_LOG_LINES}
473
+ )
474
+ ]
475
+
269
476
  try:
270
477
  raw = sys.stdin.read()
271
478
  records = json.loads(raw) if raw.strip() else []
272
- rows = to_rows(transform(records))
273
- sys.stdout.write(json.dumps(rows and {"rows": rows, "logs": logs} or {"rows": [], "logs": logs}, default=str))
479
+ # \`to_rows\` is inside the redirect as well: a lazily-evaluated return value
480
+ # does its printing here, not before.
481
+ with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
482
+ rows = to_rows(transform(records))
483
+ # Back on the real stdout by now — the context manager restores on the way
484
+ # out, including out of an exception — so this is the only thing on it.
485
+ out = captured()
486
+ sys.stdout.write(json.dumps(rows and {"rows": rows, "logs": out} or {"rows": [], "logs": out}, default=str))
274
487
  except Exception as error:
275
488
  sys.stdout.write(json.dumps({
276
489
  "error": "{}: {}".format(type(error).__name__, error),
277
- "logs": logs,
490
+ "logs": captured(),
278
491
  }))
279
492
  `;
280
493
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-catalog",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A metadata registry for NestJS: object types, properties and relations, derived from your ORM and enriched with decorators.",
5
5
  "license": "MIT",
6
6
  "author": "Davide Carvalho",