@dudousxd/nestjs-catalog 0.21.0 → 0.23.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.
@@ -12,11 +12,15 @@ var SubprocessTransformRunner_1;
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.SubprocessTransformRunner = void 0;
14
14
  const node_child_process_1 = require("node:child_process");
15
+ const node_crypto_1 = require("node:crypto");
15
16
  const node_fs_1 = require("node:fs");
17
+ const promises_1 = require("node:fs/promises");
16
18
  const node_os_1 = require("node:os");
17
19
  const node_path_1 = require("node:path");
20
+ const node_url_1 = require("node:url");
18
21
  const common_1 = require("@nestjs/common");
19
22
  const catalog_pipeline_1 = require("./catalog.pipeline");
23
+ const transform_shape_1 = require("./transform-shape");
20
24
  const DEFAULT_TIMEOUT_MS = 30_000;
21
25
  const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
22
26
  /**
@@ -127,7 +131,12 @@ const REPORTED_PACKAGES = ['pandas', 'numpy', 'pyarrow', 'requests'];
127
131
  * - it runs in a working directory of this runner's choosing but on the host's
128
132
  * filesystem, so a service account token under
129
133
  * `/var/run/secrets/kubernetes.io/serviceaccount/` is an absolute path away;
130
- * - it can open sockets, as whatever user the service runs as.
134
+ * - it can open sockets, as whatever user the service runs as;
135
+ * - a module-shaped transform is written to a file in that temporary directory
136
+ * for the length of the run, so its own source is briefly on disk. That is
137
+ * not a new exposure — the code is the thing running, and it can read itself
138
+ * from anywhere — but it is a fact worth having written down next to the
139
+ * others rather than discovered in a directory listing.
131
140
  *
132
141
  * So the allowlist is a guard rail against the accident, and the reachability of
133
142
  * everything it names is a property of the process boundary, not a leak to be
@@ -223,24 +232,40 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
223
232
  if (!interpreter) {
224
233
  throw new Error('No python3 on PATH, so python transforms cannot run here. Use javascript or typescript, or install python in the image.');
225
234
  }
226
- const script = python ? pythonHarness(transform.code) : javascriptHarness(transform.code);
227
- // `module-typescript` is Node's own stripping — types are erased, never
228
- // checked. A transform with a wrong type still runs; the editor's try pane
229
- // is what catches it, not the compiler.
230
- const args = python
231
- ? ['-c', script]
232
- : [
233
- '--input-type',
234
- transform.language === 'typescript' ? 'module-typescript' : 'module',
235
- '-e',
236
- script,
237
- ];
235
+ // Python is not asked: its harness writes the `def`, so a Python transform
236
+ // never states a signature and has nothing to detect. See
237
+ // {@link pythonHarness}.
238
+ const shape = python ? 'body' : (0, transform_shape_1.transformShape)(transform.code);
239
+ // Written to disk only for the module shape, and only for the length of the
240
+ // run. A module has to be *imported* to be a module — its `export default`
241
+ // creates no binding this harness could name, and rewriting the keyword
242
+ // into an assignment would be surgery on somebody's source. A file also
243
+ // gives Node the extension it needs to strip TypeScript, and gives the
244
+ // author stack frames with real line numbers instead of `[eval]`.
245
+ const modulePath = shape === 'module'
246
+ ? (0, node_path_1.join)((0, node_os_1.tmpdir)(), `catalog-transform-${(0, node_crypto_1.randomUUID)()}.${transform.language === 'typescript' ? 'mts' : 'mjs'}`)
247
+ : undefined;
248
+ try {
249
+ if (modulePath)
250
+ await (0, promises_1.writeFile)(modulePath, transform.code, 'utf8');
251
+ const args = interpreterArgs(transform, modulePath);
252
+ return await this.execute(interpreter, args, records, context, timeoutMs, shape, started);
253
+ }
254
+ finally {
255
+ // Unlinked whether the run returned, threw, or was killed on the timeout —
256
+ // the parent settles in all three, so nothing is left in `tmpdir` for an
257
+ // operator to find later and wonder about.
258
+ if (modulePath)
259
+ await (0, promises_1.rm)(modulePath, { force: true });
260
+ }
261
+ }
262
+ async execute(interpreter, args, records, context, timeoutMs, shape, started) {
238
263
  // An envelope rather than the bare array stdin used to carry. The context
239
264
  // travels beside the records rather than in the child's `env`, and that is
240
265
  // the deliberate half of it: the child's own environment stays
241
266
  // `{PATH, NODE_ENV}`, so nothing about what a transform may read changes by
242
267
  // accident when somebody edits the spawn options later.
243
- const { stdout, stderr } = await this.spawn(interpreter, args, JSON.stringify({ records, context }), timeoutMs);
268
+ const { stdout, stderr } = await this.spawn(interpreter, args, JSON.stringify({ records, context }), timeoutMs, shape);
244
269
  let parsed;
245
270
  try {
246
271
  // The harness prints exactly one JSON line last; anything the code wrote
@@ -262,7 +287,7 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
262
287
  elapsedMs: Date.now() - started,
263
288
  };
264
289
  }
265
- spawn(command, args, input, timeoutMs) {
290
+ spawn(command, args, input, timeoutMs, shape = 'body') {
266
291
  return new Promise((resolve, reject) => {
267
292
  const child = (0, node_child_process_1.spawn)(command, args, {
268
293
  // An empty environment, not the parent's. A transform has no business
@@ -321,7 +346,11 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
321
346
  settled = true;
322
347
  clearTimeout(timer);
323
348
  if (code !== 0 && stdout.trim().length === 0) {
324
- reject(new Error(`The transform exited with code ${code}. ${stderr.slice(0, 500)}`));
349
+ // The shape hint rides along here specifically: a body whose author
350
+ // meant it as a module fails before the harness's own try/catch is
351
+ // ever entered, so this branch is the only place that error can be
352
+ // annotated. See {@link transformShapeHint}.
353
+ reject(new Error(`The transform exited with code ${code}. ${stderr.slice(0, 500)}${(0, transform_shape_1.transformShapeHint)(shape, stderr)}`));
325
354
  return;
326
355
  }
327
356
  resolve({ stdout, stderr });
@@ -369,6 +398,27 @@ exports.SubprocessTransformRunner = SubprocessTransformRunner = SubprocessTransf
369
398
  (0, common_1.Injectable)(),
370
399
  __metadata("design:paramtypes", [Object])
371
400
  ], SubprocessTransformRunner);
401
+ /**
402
+ * What the interpreter is invoked with, and which harness it is handed.
403
+ *
404
+ * `module-typescript` is Node's own stripping — types are erased, never
405
+ * checked. A transform with a wrong type still runs; the editor's try pane is
406
+ * what catches it, not the compiler.
407
+ *
408
+ * The module shape needs none of that flag here: the harness itself is plain
409
+ * JavaScript, and the author's `.mts` file is stripped on import by its
410
+ * extension. That confines the stripper to the code that asked for it, rather
411
+ * than running this file's own generated source through it as well.
412
+ */
413
+ function interpreterArgs(transform, modulePath) {
414
+ if (transform.language === 'python')
415
+ return ['-c', pythonHarness(transform.code)];
416
+ const script = modulePath
417
+ ? javascriptModuleHarness((0, node_url_1.pathToFileURL)(modulePath).href)
418
+ : javascriptHarness(transform.code);
419
+ const inputType = transform.language === 'typescript' && !modulePath ? 'module-typescript' : 'module';
420
+ return ['--input-type', inputType, '-e', script];
421
+ }
372
422
  /**
373
423
  * The context for a run that has none: a spec, or a host driving the runner by
374
424
  * hand.
@@ -437,7 +487,15 @@ function withFinalLogs(error, logs) {
437
487
  return `${error}\n${heading}\n${tail.map((line) => ` ${line}`).join('\n')}`;
438
488
  }
439
489
  /**
440
- * The JavaScript and TypeScript harness.
490
+ * The JavaScript and TypeScript harness for the **bare-body** shape: the
491
+ * author's code is the inside of a function this string writes.
492
+ *
493
+ * Unchanged, and that is the feature. Every transform stored before the module
494
+ * shape existed is a bare body, and it runs through the identical wrapper with
495
+ * the identical positional parameters and the identical interpreter flags — the
496
+ * new shape is a second path beside this one, not a rewrite of it. See
497
+ * `transform-shape.ts` for the rule that decides which path a given piece of
498
+ * code takes, and why a stored transform cannot be sent down the wrong one.
441
499
  *
442
500
  * `console.log` is captured rather than left on stdout so user code cannot
443
501
  * corrupt the single JSON line this prints — a transform that logs a `{` would
@@ -462,7 +520,84 @@ function withFinalLogs(error, logs) {
462
520
  * gets as far as being serialised.
463
521
  */
464
522
  function javascriptHarness(code) {
465
- return `
523
+ return `${JAVASCRIPT_PRELUDE}
524
+ try {
525
+ ${JAVASCRIPT_PAYLOAD}
526
+ const transform = async (records, context) => { ${code} };
527
+ const rows = await transform(records, context);
528
+ process.stdout.write(JSON.stringify({ rows: rows ?? [], logs: captured() }));
529
+ } catch (error) {
530
+ ${JAVASCRIPT_FAILURE}
531
+ }
532
+ `;
533
+ }
534
+ /**
535
+ * The harness for the module shape: import the author's module, call what it
536
+ * exports with one object.
537
+ *
538
+ * Everything above the call is the same prelude the bare-body harness uses —
539
+ * the same six console channels, the same two caps, the same envelope, the same
540
+ * frozen `context`. A transform's log behaviour changing because of the shape it
541
+ * happens to be written in would be exactly as surprising as it changing because
542
+ * of the language, and the constants say why that is not allowed to happen.
543
+ *
544
+ * The code arrives as a **file URL**, not as text spliced into this string, and
545
+ * the difference matters three times over. `export default` binds nothing that
546
+ * an enclosing scope could name, so the module genuinely has to be imported;
547
+ * `.mts` is what tells Node to strip the types, so the extension does the job
548
+ * `--input-type module-typescript` does for a body; and a stack frame reads
549
+ * `catalog-transform-….mts:3:11` rather than `[eval]`, which is the difference
550
+ * between a line number and a shrug.
551
+ *
552
+ * ## What it accepts, and what it refuses
553
+ *
554
+ * `export default`, or a named export called `transform`. Two spellings rather
555
+ * than one because both are things people write without being told to, and
556
+ * because both are *real exports* — neither is a guess about a name in scope.
557
+ *
558
+ * A module that exports neither is **refused, by name**. The alternative is a
559
+ * transform that returns no rows and says nothing about why, which downstream
560
+ * reads as a source that produced nothing — a connector would commit an empty
561
+ * snapshot over live data on the strength of a missing `default` keyword.
562
+ *
563
+ * The import is deliberately not wrapped in a fallback to the body shape. Code
564
+ * that fails to parse as a module has one honest answer — the parse error, with
565
+ * the line — and re-running it in the other shape would replace that with a
566
+ * second, different error about text the author never wrote.
567
+ */
568
+ function javascriptModuleHarness(moduleUrl) {
569
+ return `${JAVASCRIPT_PRELUDE}
570
+ try {
571
+ ${JAVASCRIPT_PAYLOAD}
572
+ const mod = await import(${JSON.stringify(moduleUrl)});
573
+ const exported = typeof mod.default === "function"
574
+ ? mod.default
575
+ : typeof mod.transform === "function" ? mod.transform : null;
576
+ if (!exported) {
577
+ const names = Object.keys(mod).filter((key) => key !== "default");
578
+ throw new Error(
579
+ "This transform is a module — it has a top-level \`export\` — so the catalog imported it and " +
580
+ "looked for a function to call. \`export default\` is " + (("default" in mod) ? typeof mod.default : "missing") +
581
+ " and there is no exported \`transform\` function." +
582
+ (names.length > 0 ? " It does export: " + names.join(", ") + "." : "") +
583
+ " Export the function as \`export default\`, or name it \`transform\`."
584
+ );
585
+ }
586
+ const rows = await exported({ records, context });
587
+ process.stdout.write(JSON.stringify({ rows: rows ?? [], logs: captured() }));
588
+ } catch (error) {
589
+ ${JAVASCRIPT_FAILURE}
590
+ }
591
+ `;
592
+ }
593
+ /**
594
+ * Capture the console, before any of the author's code can reach it.
595
+ *
596
+ * Shared verbatim by both JavaScript harnesses rather than copied into each: two
597
+ * copies of a log cap are two numbers that drift, and the one that drifts is
598
+ * discovered by a run record nobody can explain.
599
+ */
600
+ const JAVASCRIPT_PRELUDE = `
466
601
  const logs = [];
467
602
  let dropped = 0;
468
603
  const keep = (line) => {
@@ -483,8 +618,9 @@ const captured = () => dropped === 0
483
618
  let input = "";
484
619
  process.stdin.setEncoding("utf8");
485
620
  for await (const chunk of process.stdin) input += chunk;
486
-
487
- try {
621
+ `;
622
+ /** Unpack the envelope. `context` is frozen one level down — see below. */
623
+ const JAVASCRIPT_PAYLOAD = `
488
624
  const payload = JSON.parse(input || "{}");
489
625
  const records = Array.isArray(payload.records) ? payload.records : [];
490
626
  // Frozen, and one level down as well, so that a transform assigning to
@@ -492,20 +628,42 @@ try {
492
628
  // then confusing whoever reads the next node's code. Nothing propagates out
493
629
  // of this process either way; the freeze buys the honest error, not safety.
494
630
  const context = Object.freeze({ ...payload.context, env: Object.freeze({ ...payload.context?.env }) });
495
- const transform = async (records, context) => { ${code} };
496
- const rows = await transform(records, context);
497
- process.stdout.write(JSON.stringify({ rows: rows ?? [], logs: captured() }));
498
- } catch (error) {
631
+ `;
632
+ /** The one JSON line a failed run prints, logs and all. */
633
+ const JAVASCRIPT_FAILURE = `
499
634
  process.stdout.write(JSON.stringify({
500
635
  error: error instanceof Error ? \`\${error.name}: \${error.message}\` : String(error),
501
636
  logs: captured(),
502
637
  }));
503
- }
504
638
  `;
505
- }
506
639
  /**
507
640
  * The Python harness. `records` in, a list of dicts out.
508
641
  *
642
+ * ## Why this did not move to the one-object shape
643
+ *
644
+ * JavaScript moved because a JavaScript transform *states its own signature* —
645
+ * the harness wrote `(records, context)` and the author's code depended on both
646
+ * names being where they were, so a third parameter would have been a change to
647
+ * text nobody was going to re-read. A Python transform states nothing. This
648
+ * harness writes `def transform(records, context):` and indents the author's
649
+ * code into it, so a fourth thing to pass is **one line changed here** and not a
650
+ * single stored transform touched. Python already has the property the object
651
+ * shape was introduced to buy.
652
+ *
653
+ * Moving it anyway would cost the thing the move was for. `records` and
654
+ * `context` are names in scope today; a `payload` dict makes them
655
+ * `payload["records"]` and `payload["context"]`, which is a break in every
656
+ * Python transform in existence — the exact outcome the JavaScript change was
657
+ * designed to avoid. Consistency between the two languages is worth something,
658
+ * but not a migration bought with somebody else's pandas code, and not when the
659
+ * inconsistency is *because* the two languages start from different places.
660
+ *
661
+ * The asymmetry left standing, said out loud: a Python author who writes their
662
+ * own `def transform(...)` at column 0 gets it indented into a nested
663
+ * definition, and the outer `transform` returns `None` — no error, no rows. That
664
+ * is a real footgun and it is older than this change; it is named here so the
665
+ * next person to open this file knows it is known rather than missed.
666
+ *
509
667
  * A DataFrame is accepted as a return value and converted, because a transform
510
668
  * that reaches for pandas will naturally end with one — making it write
511
669
  * `.to_dict("records")` would be a papercut on the only path pandas is worth
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Which of the two shapes a JavaScript or TypeScript transform is written in,
3
+ * and the one rule that tells them apart.
4
+ *
5
+ * ## The two shapes
6
+ *
7
+ * A transform used to be — and, for everything already stored, still is — a
8
+ * **bare body**: the text between the braces of a function the harness supplies.
9
+ *
10
+ * ```js
11
+ * return records.map((r) => ({ mgmtCd: r["Mgmt Cd"] }));
12
+ * ```
13
+ *
14
+ * That shape works, and its problem is not syntax. The harness supplied
15
+ * `(records, context)` **positionally**, so the set of things a transform can be
16
+ * given was fixed by the day the second parameter was added: a third one changes
17
+ * the meaning of every signature ever written, and there is no version of
18
+ * "records, context, andNowAlsoThis" that does not make the previous shape a
19
+ * subset by luck rather than by design. So the supported shape is now a real
20
+ * function over **one object**:
21
+ *
22
+ * ```js
23
+ * export default function transform({ records, context }) {
24
+ * return records.map((r) => ({ mgmtCd: r["Mgmt Cd"] }));
25
+ * }
26
+ * ```
27
+ *
28
+ * A field can be added to that object without touching a single stored
29
+ * transform, which is the entire argument for it.
30
+ *
31
+ * ## The rule
32
+ *
33
+ * **A top-level `export` keyword, and nothing else.** Code that has one is a
34
+ * module; code that has none is a body.
35
+ *
36
+ * That is a discriminator rather than a heuristic, and the reason is worth being
37
+ * exact about: `export` is a *syntax error* inside a function body. Every
38
+ * transform stored today runs as a function body today, so no stored transform
39
+ * can contain a top-level `export` — not "probably does not", cannot. Backward
40
+ * compatibility here is a property of the language, not of how good the guess is.
41
+ *
42
+ * ## What the rule deliberately does not look at
43
+ *
44
+ * Not the word `function`. Not a function *declaration* named `transform`
45
+ * either, which is the tempting second rule and is the one that would break
46
+ * real code:
47
+ *
48
+ * ```js
49
+ * function transform(r) { return { mgmtCd: r["Mgmt Cd"] }; }
50
+ * return records.map(transform);
51
+ * ```
52
+ *
53
+ * That is a bare body which declares a local helper it happens to have named
54
+ * `transform`. A detector that called it the new shape would call the helper
55
+ * with `{records, context}` — one object where a record was expected — and store
56
+ * 100,000 rows of `undefined` without erroring once. Silent wrong data is the
57
+ * worst failure available here, so the detector does not offer an opinion about
58
+ * names at all.
59
+ *
60
+ * ## The scan, and its one known limit
61
+ *
62
+ * Strings, template literals (including `${}` nesting), regular-expression
63
+ * literals and both kinds of comment are skipped, so `// export default` and
64
+ * `"export"` are not exports. The keyword must then appear at **statement
65
+ * position** — start of input, or after `;`, `}`, or a newline — at brace,
66
+ * paren and bracket depth zero.
67
+ *
68
+ * Regular-expression literals are found by the usual rule (a `/` is a regex
69
+ * unless what precedes it could end a value), which is the one place a scanner
70
+ * without a full parser can be wrong. Its consequence is bounded on purpose: a
71
+ * misread makes this return `false` for a module, the code is run as a body, and
72
+ * the author gets `SyntaxError: Unexpected token 'export'` — which
73
+ * {@link transformShapeHint} turns into a sentence naming this exact rule. A
74
+ * wrong answer here produces a reported error, never a silently different run.
75
+ */
76
+ /** What shape a transform's code is in. */
77
+ export type TransformShape = 'module' | 'body';
78
+ /**
79
+ * Does this code declare an ES module — and so ask to be called as a function
80
+ * over one object?
81
+ *
82
+ * See the module docblock for the rule and why it is the rule. Python is not
83
+ * asked this question: its harness writes the `def` itself, so a Python
84
+ * transform never states a signature and never had the problem this detector
85
+ * exists to solve.
86
+ */
87
+ export declare function transformDeclaresModule(code: string): boolean;
88
+ /** {@link transformDeclaresModule}, as the shape it names. */
89
+ export declare function transformShape(code: string): TransformShape;
90
+ /**
91
+ * The sentence to add when a body-shaped run died on the one syntax error that
92
+ * means the detector and the author disagreed.
93
+ *
94
+ * The rule above is a scan, not a parser, and its documented limit is that a
95
+ * regular-expression literal read as a division can hide a real `export`. The
96
+ * author then sees `Unexpected token 'export'` from code they believe is a
97
+ * perfectly good module, and has no way to know that a *rule they have never
98
+ * read* is what decided otherwise. Naming the rule in the error is the whole
99
+ * difference between a two-minute fix and an afternoon.
100
+ *
101
+ * Deliberately not a fallback re-run in the other shape. Running code twice
102
+ * because the first attempt failed is guessing with extra steps: the second
103
+ * attempt would report a different error for the same text, and neither error
104
+ * would be trustworthy.
105
+ */
106
+ export declare function transformShapeHint(shape: TransformShape, stderr: string): string;