@dudousxd/nestjs-catalog 0.22.0 → 0.24.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.
@@ -23,6 +23,51 @@ const catalog_pipeline_1 = require("./catalog.pipeline");
23
23
  const transform_shape_1 = require("./transform-shape");
24
24
  const DEFAULT_TIMEOUT_MS = 30_000;
25
25
  const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
26
+ /**
27
+ * How many records ride on one line of the wire into the child.
28
+ *
29
+ * **Framing only.** The child calls the author's function once per record
30
+ * whatever this is; nothing about the contract, the memory bound or what a
31
+ * transform can see changes with the number. What changes is how many times
32
+ * `JSON.parse` is entered, and that turned out to be worth measuring rather than
33
+ * assuming: `bench/transform-stream.mjs` runs the identical rename at one record
34
+ * per line and at five hundred, and over the real 102,520-row `af_fleet.csv` the
35
+ * batched wire is about 7% faster end to end and holds the same memory.
36
+ *
37
+ * Five hundred, not five thousand, because this is the one buffer on the write
38
+ * side that scales with nothing else: it is the *only* thing standing between a
39
+ * streamed source and a bounded parent, so it is kept at the same order as the
40
+ * `BATCH_SIZE` the pipeline's own writers use. At five columns that is roughly
41
+ * 40 KB in flight, which is not a memory decision anybody has to think about.
42
+ */
43
+ const RECORDS_PER_LINE = 500;
44
+ /**
45
+ * When the child stops accumulating rows and puts a line on the wire.
46
+ *
47
+ * Two triggers rather than one, and each covers a case the other cannot.
48
+ *
49
+ * The **byte** trigger is the memory bound: a transform that fans one record out
50
+ * into thousands of rows would otherwise hold all of them until the record after
51
+ * it, and the child's heap would be a property of somebody's data.
52
+ *
53
+ * The **record** trigger is the liveness bound, and it is why the flush happens
54
+ * even when there is nothing to say. A per-record transform that drops most of
55
+ * what it sees — a normaliser that returns `[]` for a blank row — emits no bytes
56
+ * for a long time, and a parent watching only the byte stream cannot tell that
57
+ * from a child hung on record 60,000. The empty line carries `at`, so the stall
58
+ * clock has something to reset on. See {@link SubprocessTransformRunner.runStream}.
59
+ *
60
+ * It is also the **attribution window**, which is what fixes its size at 200
61
+ * rather than at a round thousand. A child killed mid-record cannot report where
62
+ * it got to, so the finest a failure can be located is "after the last `at` the
63
+ * child sent, and before the last record the parent sent" — and this number is
64
+ * the first half of that. Two hundred costs about five hundred extra lines over a
65
+ * hundred-thousand-record load, which is nothing measurable, and buys a window
66
+ * five times narrower for the person reading the failure. {@link stallError}
67
+ * states the window rather than picking a record inside it.
68
+ */
69
+ const FLUSH_BYTES = 256 * 1024;
70
+ const FLUSH_RECORDS = 200;
26
71
  /**
27
72
  * How much of the child's stderr is held, and why it is a different number from
28
73
  * {@link MAX_OUTPUT_BYTES} with a different consequence.
@@ -62,6 +107,41 @@ const MAX_CAPTURED_STDERR_BYTES = 64 * 1024;
62
107
  * would throw on every timeout.
63
108
  */
64
109
  const KILL_PROCESS_GROUP = process.platform !== 'win32';
110
+ /**
111
+ * The whole of the isolation, in one object, spawned identically by every path
112
+ * in this file.
113
+ *
114
+ * One object rather than a literal per call site, and that is the point rather
115
+ * than tidiness. The class docblock spends four paragraphs being exact about
116
+ * what a transform can and cannot reach, and every sentence of it is a claim
117
+ * about *these three fields*. A second literal somewhere else in the file would
118
+ * be a second answer to "what environment does user code get", and the one that
119
+ * drifted would be discovered by a transform reading something the docblock says
120
+ * it cannot. The streaming path added below reuses this untouched, which is why
121
+ * "a streamed transform is isolated exactly as a batched one is" is a property of
122
+ * the code rather than a promise in a comment.
123
+ *
124
+ * - **`env`** is `{PATH, NODE_ENV}` and not the parent's. A transform has no
125
+ * business reading the database password, and inheriting env is how it would.
126
+ * Read the class docblock before treating this as containment: the same values
127
+ * are a `/proc/<ppid>/environ` read away, and this is a guard rail against the
128
+ * accidental read rather than a boundary.
129
+ * - **`cwd`** is not the parent's, which is a running service's directory and
130
+ * holds the `.env` the allowlist exists to withhold — a transform whose first
131
+ * line is `readFileSync(".env")` was reading the host application's
132
+ * configuration by relative path. A temporary directory keeps the file writes a
133
+ * transform may legitimately want working while making the one path it can name
134
+ * without knowing anything about the deployment uninteresting. Absolute paths
135
+ * are unaffected, and cannot be.
136
+ * - **`detached`** puts the child in its own process group, so a timeout can
137
+ * reach a grandchild. See {@link KILL_PROCESS_GROUP} and {@link stop}.
138
+ */
139
+ const CHILD_PROCESS_OPTIONS = {
140
+ env: { PATH: process.env.PATH ?? '', NODE_ENV: 'production' },
141
+ cwd: (0, node_os_1.tmpdir)(),
142
+ detached: KILL_PROCESS_GROUP,
143
+ stdio: ['pipe', 'pipe', 'pipe'],
144
+ };
65
145
  /**
66
146
  * How much of what a transform logged is carried back, on both axes.
67
147
  *
@@ -259,6 +339,116 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
259
339
  await (0, promises_1.rm)(modulePath, { force: true });
260
340
  }
261
341
  }
342
+ /**
343
+ * Run a `'record'`-mode transform over a stream, and hand the rows back as a
344
+ * stream.
345
+ *
346
+ * ## What is and is not different from {@link run}
347
+ *
348
+ * **The isolation is not different, at all.** Same interpreter, same
349
+ * {@link CHILD_PROCESS_OPTIONS} — the same `{PATH, NODE_ENV}`, the same
350
+ * temporary cwd, the same process group — and the context still travels beside
351
+ * the records on stdin rather than in the environment. It is worth being blunt
352
+ * about why that is easy to say: the child here is **not longer-lived than the
353
+ * one `run` spawns.** Both live for exactly one node run and are gone when it
354
+ * ends. `run` was never a spawn per batch; it was a spawn per node with the
355
+ * whole dataset in one blob. So there is no new window in which state could
356
+ * leak between batches or between runs, because there was never a window to
357
+ * widen. What a transform may retain *within* one run is stated exactly on
358
+ * {@link javascriptRecordHarness}.
359
+ *
360
+ * **The timeout is different, and it has to be.** See {@link stallError}.
361
+ *
362
+ * **Failure names a record.** A batch call can only report that the transform
363
+ * threw; here the child counts what it has consumed and puts that number on
364
+ * every line, so a stream that dies names the record it died on. What was
365
+ * already staged is a matter for the caller — for the connector runner it sits
366
+ * in an uncommitted snapshot, for a workflow node it is overwritten by the next
367
+ * attempt — and in neither case does a watermark move, because nothing here
368
+ * reaches a commit.
369
+ *
370
+ * ## The one shape that would deadlock, and how it is avoided
371
+ *
372
+ * The writer runs as a floating loop and the reader is driven by the consumer
373
+ * pulling rows. Awaiting the writer before yielding the first row is the one
374
+ * arrangement that hangs: the child fills its stdout buffer with rows nobody is
375
+ * draining, stops reading stdin, and this side waits forever for a `drain` that
376
+ * requires the reader that has not started. Written the way it is, a slow
377
+ * consumer simply back-pressures the whole chain, which is the point.
378
+ */
379
+ async runStream(transform, records, options = {}) {
380
+ const mode = (0, catalog_pipeline_1.transformMode)(transform);
381
+ if (mode === 'batch') {
382
+ throw new Error('This transform is a function over the whole batch, so it cannot be streamed a record at a time — that would call it once per record and return one partial answer per record, which is exactly the silent wrong result TRANSFORM_MODES exists to prevent. Run it through `run`, or set the transform to per-record mode.');
383
+ }
384
+ if (mode !== 'record')
385
+ return (0, catalog_pipeline_1.unreachableTransformMode)(mode, 'SubprocessTransformRunner.runStream');
386
+ // Asked here as well as at the controller, because a transform row can reach
387
+ // a runner without ever passing through this build's controller — promoted
388
+ // from another environment, restored, written by an older version — and the
389
+ // failure this must never have is the quiet one.
390
+ const refusal = (0, catalog_pipeline_1.recordModeRefusal)(transform);
391
+ if (refusal)
392
+ throw new Error(refusal);
393
+ const started = Date.now();
394
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
395
+ // `rowCount` is 0 rather than a guess: a stream does not know how many
396
+ // records there are, and `contextlessRun` is only reached by a caller that
397
+ // gave no context at all. A caller with a real run supplies the count it
398
+ // knows, exactly as it does for a batch.
399
+ const context = options.context ?? contextlessRun(0);
400
+ // A record-mode transform is always a module — `recordModeRefusal` has just
401
+ // refused anything else — so the file is unconditional here where `run` has
402
+ // to decide. Removed in the generator's `finally` rather than in one here,
403
+ // because the run is not over when this method returns: it is over when the
404
+ // rows have been drained, thrown, or abandoned by a `break`.
405
+ const modulePath = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `catalog-transform-${(0, node_crypto_1.randomUUID)()}.${transform.language === 'typescript' ? 'mts' : 'mjs'}`);
406
+ await (0, promises_1.writeFile)(modulePath, transform.code, 'utf8');
407
+ const child = (0, node_child_process_1.spawn)(process.execPath, ['--input-type', 'module', '-e', javascriptRecordHarness((0, node_url_1.pathToFileURL)(modulePath).href)], CHILD_PROCESS_OPTIONS);
408
+ const pump = new RecordStreamPump(child, timeoutMs);
409
+ pump.feed(records, context);
410
+ let summary;
411
+ const logger = this.logger;
412
+ async function* rows() {
413
+ try {
414
+ for await (const message of pump.messages()) {
415
+ if (message.done) {
416
+ summary = { ...message.done, elapsedMs: Date.now() - started };
417
+ continue;
418
+ }
419
+ for (const row of message.rows) {
420
+ // The same filter `run` applies before it returns, through the same
421
+ // predicate: anything that is not a plain object cannot be written
422
+ // as a row, and dropping it silently downstream is how a load comes
423
+ // out short with nothing to explain it.
424
+ if (isRowObject(row))
425
+ yield row;
426
+ }
427
+ }
428
+ }
429
+ finally {
430
+ // Every exit: drained, thrown, or a `break` in the consumer's loop. The
431
+ // last one is why this cannot be a `finally` around the spawn — a
432
+ // consumer that stops early leaves a child holding a pipe, and the kill
433
+ // has to happen where the abandonment is observable.
434
+ pump.close();
435
+ await (0, promises_1.rm)(modulePath, { force: true }).catch(() => {
436
+ // A temporary file that will not unlink is not worth failing a load
437
+ // that produced correct rows. It is logged rather than thrown.
438
+ logger.warn(`Could not remove the transform's temporary module at ${modulePath}.`);
439
+ });
440
+ }
441
+ }
442
+ return {
443
+ rows: rows(),
444
+ summary: () => {
445
+ if (!summary) {
446
+ throw new Error('The transform stream has not finished, so there is no summary yet. `summary()` is answered only once `rows` is exhausted — a running total asked for early is exactly the number somebody would go on to record as `fetched`.');
447
+ }
448
+ return summary;
449
+ },
450
+ };
451
+ }
262
452
  async execute(interpreter, args, records, context, timeoutMs, shape, started) {
263
453
  // An envelope rather than the bare array stdin used to carry. The context
264
454
  // travels beside the records rather than in the child's `env`, and that is
@@ -282,33 +472,14 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
282
472
  throw new Error('The transform must return an array of rows. Returning anything else would leave the load ambiguous.');
283
473
  }
284
474
  return {
285
- rows: parsed.rows.filter((row) => typeof row === 'object' && row !== null && !Array.isArray(row)),
475
+ rows: parsed.rows.filter(isRowObject),
286
476
  logs,
287
477
  elapsedMs: Date.now() - started,
288
478
  };
289
479
  }
290
480
  spawn(command, args, input, timeoutMs, shape = 'body') {
291
481
  return new Promise((resolve, reject) => {
292
- const child = (0, node_child_process_1.spawn)(command, args, {
293
- // An empty environment, not the parent's. A transform has no business
294
- // reading the database password, and inheriting env is how it would.
295
- // Read the class docblock before treating this as containment: the same
296
- // values are a `/proc/<ppid>/environ` read away, and this is a guard
297
- // rail against the accidental read rather than a boundary.
298
- env: { PATH: process.env.PATH ?? '', NODE_ENV: 'production' },
299
- // Not the parent's, which is a running service's directory and holds
300
- // the `.env` the allowlist above exists to withhold — a transform whose
301
- // first line is `readFileSync(".env")` was reading the host application's
302
- // configuration by relative path. A temporary directory keeps the file
303
- // writes a transform may legitimately want working while making the one
304
- // path it can name without knowing anything about the deployment
305
- // uninteresting. Absolute paths are unaffected, and cannot be.
306
- cwd: (0, node_os_1.tmpdir)(),
307
- // Its own process group, so the timeout below can reach a grandchild.
308
- // See {@link KILL_PROCESS_GROUP}.
309
- detached: KILL_PROCESS_GROUP,
310
- stdio: ['pipe', 'pipe', 'pipe'],
311
- });
482
+ const child = (0, node_child_process_1.spawn)(command, args, CHILD_PROCESS_OPTIONS);
312
483
  let stdout = '';
313
484
  let stderr = '';
314
485
  let settled = false;
@@ -398,6 +569,313 @@ exports.SubprocessTransformRunner = SubprocessTransformRunner = SubprocessTransf
398
569
  (0, common_1.Injectable)(),
399
570
  __metadata("design:paramtypes", [Object])
400
571
  ], SubprocessTransformRunner);
572
+ /**
573
+ * Whether this is something that can be stored as a row.
574
+ *
575
+ * One predicate for both paths rather than the same three clauses written twice.
576
+ * "A row is a plain object" is a rule the publish side already depends on, and
577
+ * two copies of it are two places for an array or a `null` to start counting as
578
+ * a row in one mode and not the other.
579
+ */
580
+ function isRowObject(value) {
581
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
582
+ }
583
+ /**
584
+ * Both halves of the pipe to a record-mode child: records in, rows out, and the
585
+ * clock that decides the child has stopped.
586
+ *
587
+ * A class rather than closures inside `runStream` because the two halves have to
588
+ * see each other. The stall clock cannot be armed by the reader alone — whether
589
+ * the child *owes* an answer is a fact about what the writer has sent — and the
590
+ * writer cannot arm it either, because whether anybody is *waiting* is a fact
591
+ * about the reader. Separating them into two functions would mean passing a
592
+ * mutable cell between them, which is this object with the name taken off.
593
+ *
594
+ * ## The stall clock, and why it is not the batch timeout
595
+ *
596
+ * `run` bounds total wall clock: the whole payload is written at once, so
597
+ * "elapsed" is time the child spent working and nothing else. That bound cannot
598
+ * be carried over unchanged, and this is the one genuine semantic change in the
599
+ * streaming path, so it is worth stating rather than discovering. A streamed
600
+ * transform's clock would include **time waiting for the source** — a SQL cursor
601
+ * paging over ten million rows, an S3 prefix listing — which the batch path
602
+ * finished before it spawned anything. Thirty seconds of total wall clock would
603
+ * therefore fail loads that work today, for reasons that have nothing to do with
604
+ * the transform.
605
+ *
606
+ * So what is bounded is a **stall**: `timeoutMs` with the child owing an answer
607
+ * and nobody hearing one. The three states are kept apart deliberately —
608
+ *
609
+ * - the child is hung on a record → the reader is waiting, records are
610
+ * outstanding, the clock runs, and at `timeoutMs` the process group is killed.
611
+ * This is the failure the timeout exists for and it is caught *sooner* than the
612
+ * old bound caught it on a long load;
613
+ * - the source is slow → the child has answered everything it was given, nothing
614
+ * is outstanding, the clock is not running. A load that spends an hour waiting
615
+ * on a database is not a stalled transform;
616
+ * - the consumer is slow → the reader is not waiting on the child at all, so the
617
+ * clock is not running. A sink writing to a busy warehouse back-pressures the
618
+ * whole chain, which is the design, and must not read as a hang.
619
+ *
620
+ * What is given up is a bound on **total** time, and that is deliberate: a
621
+ * stream's total time is a property of how much data there is, so a total bound
622
+ * is a bound on dataset size wearing a clock's clothes. The outer bound has not
623
+ * disappeared — a node runs inside a durable step, and `abandoned-runs.ts` is
624
+ * what closes a run whose worker went away.
625
+ *
626
+ * On the kill, rows already yielded have already been written by whoever was
627
+ * consuming them. Nothing promotes a watermark or commits a snapshot on this
628
+ * path, so "staged but not committed" is where they stay — the same place every
629
+ * other mid-run failure leaves its work.
630
+ */
631
+ class RecordStreamPump {
632
+ child;
633
+ timeoutMs;
634
+ chunks = [];
635
+ carry = '';
636
+ stderr = '';
637
+ /** How many records the writer has put on the wire. */
638
+ sent = 0;
639
+ /** How many the child has said it consumed, from the last line it wrote. */
640
+ answered = 0;
641
+ waiting = false;
642
+ timer;
643
+ wake;
644
+ fail;
645
+ ended = false;
646
+ failure;
647
+ /** Set once, and preferred over any later error: the reason we killed it. */
648
+ stalled = false;
649
+ constructor(child, timeoutMs) {
650
+ this.child = child;
651
+ this.timeoutMs = timeoutMs;
652
+ child.stdout.setEncoding('utf8');
653
+ child.stdout.on('data', (chunk) => {
654
+ this.chunks.push(chunk);
655
+ this.notify();
656
+ });
657
+ child.stderr.on('data', (chunk) => {
658
+ // Bounded exactly as the batch path bounds it, and for the reason
659
+ // {@link MAX_CAPTURED_STDERR_BYTES} gives at length: a transform looping on
660
+ // writes to fd 2 grows the *parent's* heap, and a stream gives it longer to
661
+ // do so.
662
+ if (this.stderr.length >= MAX_CAPTURED_STDERR_BYTES)
663
+ return;
664
+ this.stderr += chunk.toString().slice(0, MAX_CAPTURED_STDERR_BYTES - this.stderr.length);
665
+ });
666
+ child.on('error', (error) => this.abort(error));
667
+ child.on('close', (code) => {
668
+ this.ended = true;
669
+ if (code !== 0 && !this.failure) {
670
+ this.failure = new Error(`The transform exited with code ${code} after ${this.answered} record(s), before it finished the stream. ${this.stderr.slice(0, 500)}`);
671
+ }
672
+ this.notify();
673
+ });
674
+ }
675
+ /**
676
+ * Write the context, then the records, in lines of
677
+ * {@link RECORDS_PER_LINE}.
678
+ *
679
+ * Floating on purpose — see {@link SubprocessTransformRunner.runStream} on the
680
+ * deadlock this shape avoids. Its failure is recorded rather than thrown into
681
+ * nowhere: a source that dies mid-read must surface on the row stream, not as
682
+ * an unhandled rejection with the run reporting a short but successful load.
683
+ */
684
+ feed(records, context) {
685
+ void (async () => {
686
+ const stdin = this.child.stdin;
687
+ await write(stdin, `${JSON.stringify(context)}\n`);
688
+ let line = [];
689
+ for await (const record of records) {
690
+ line.push(record);
691
+ if (line.length < RECORDS_PER_LINE)
692
+ continue;
693
+ await write(stdin, `${JSON.stringify(line)}\n`);
694
+ this.sent += line.length;
695
+ line = [];
696
+ // The clock may have been idle while the child had nothing outstanding.
697
+ // Sending work is one of the two events that can start it running.
698
+ this.rearm();
699
+ }
700
+ if (line.length > 0) {
701
+ await write(stdin, `${JSON.stringify(line)}\n`);
702
+ this.sent += line.length;
703
+ this.rearm();
704
+ }
705
+ stdin.end();
706
+ })().catch((error) => {
707
+ this.abort(error instanceof Error ? error : new Error(String(error)));
708
+ });
709
+ }
710
+ /** Every line the child wrote, in order, until it says it is done or it fails. */
711
+ async *messages() {
712
+ for (;;) {
713
+ const line = await this.nextLine();
714
+ if (line === undefined) {
715
+ if (this.failure)
716
+ throw this.failure;
717
+ return;
718
+ }
719
+ // `parse` throws on the child's failure line, so that the record number is
720
+ // in the sentence a consumer sees rather than in a field nobody reads.
721
+ const message = this.parse(line);
722
+ this.answered = message.at;
723
+ yield message;
724
+ if (message.done)
725
+ return;
726
+ }
727
+ }
728
+ /** Kill the child and everything it started. Idempotent. */
729
+ close() {
730
+ this.clearTimer();
731
+ if (!this.ended)
732
+ stop(this.child);
733
+ }
734
+ parse(line) {
735
+ let parsed;
736
+ try {
737
+ parsed = JSON.parse(line);
738
+ }
739
+ catch {
740
+ throw new Error(`The transform wrote a line this runner could not read after ${this.answered} record(s). That is a bug in the catalog's harness rather than in the transform. stderr: ${this.stderr.slice(0, 500)}`);
741
+ }
742
+ if (parsed.failed) {
743
+ const at = typeof parsed.failed.at === 'number' ? parsed.failed.at : this.answered + 1;
744
+ const logs = Array.isArray(parsed.failed.logs) ? parsed.failed.logs.map(String) : [];
745
+ throw new Error(withFinalLogs(`The transform failed on record ${at}: ${String(parsed.failed.error)}`, logs));
746
+ }
747
+ if (isDoneLine(parsed.done)) {
748
+ return { at: parsed.done.recordsIn, rows: [], done: parsed.done };
749
+ }
750
+ return {
751
+ at: typeof parsed.at === 'number' ? parsed.at : this.answered,
752
+ rows: Array.isArray(parsed.rows) ? parsed.rows : [],
753
+ };
754
+ }
755
+ /** One complete line, or `undefined` when the child has closed for good. */
756
+ async nextLine() {
757
+ for (;;) {
758
+ const nl = this.carry.indexOf('\n');
759
+ if (nl !== -1) {
760
+ const line = this.carry.slice(0, nl);
761
+ this.carry = this.carry.slice(nl + 1);
762
+ if (line.length > 0)
763
+ return line;
764
+ continue;
765
+ }
766
+ if (this.chunks.length > 0) {
767
+ this.carry += this.chunks.shift();
768
+ if (this.carry.length > MAX_OUTPUT_BYTES) {
769
+ this.abort(new Error(`The transform wrote more than ${MAX_OUTPUT_BYTES} bytes without a line break, at record ${this.answered}. One record cannot fan out to more rows than the runner can hold.`));
770
+ }
771
+ continue;
772
+ }
773
+ if (this.ended)
774
+ return undefined;
775
+ if (this.failure)
776
+ throw this.failure;
777
+ await this.awaitChunk();
778
+ }
779
+ }
780
+ /**
781
+ * Wait for the child to say something, with the clock running only if it owes
782
+ * us an answer.
783
+ *
784
+ * This method *is* the arming rule: the clock runs while, and only while,
785
+ * control is inside it and {@link owes} holds.
786
+ */
787
+ awaitChunk() {
788
+ return new Promise((resolve, reject) => {
789
+ this.waiting = true;
790
+ this.wake = () => {
791
+ this.waiting = false;
792
+ this.wake = undefined;
793
+ this.fail = undefined;
794
+ this.clearTimer();
795
+ resolve();
796
+ };
797
+ this.fail = (error) => {
798
+ this.waiting = false;
799
+ this.wake = undefined;
800
+ this.fail = undefined;
801
+ this.clearTimer();
802
+ reject(error);
803
+ };
804
+ this.rearm();
805
+ });
806
+ }
807
+ /** Any output at all is progress: the clock stops, and whoever waits wakes. */
808
+ notify() {
809
+ this.clearTimer();
810
+ this.wake?.();
811
+ }
812
+ owes() {
813
+ return this.sent > this.answered && !this.ended;
814
+ }
815
+ rearm() {
816
+ this.clearTimer();
817
+ if (!this.waiting || !this.owes())
818
+ return;
819
+ this.timer = setTimeout(() => {
820
+ this.stalled = true;
821
+ this.abort(stallError(this.timeoutMs, this.answered, this.sent));
822
+ }, this.timeoutMs);
823
+ }
824
+ clearTimer() {
825
+ if (this.timer === undefined)
826
+ return;
827
+ clearTimeout(this.timer);
828
+ this.timer = undefined;
829
+ }
830
+ abort(error) {
831
+ // The first failure wins, except that a stall always does: a killed child
832
+ // then closes with a signal, and reporting "exited with code null" instead of
833
+ // "made no progress for 30000ms" would name the symptom rather than the
834
+ // cause.
835
+ if (!this.failure || this.stalled)
836
+ this.failure = error;
837
+ this.close();
838
+ this.ended = true;
839
+ if (this.fail)
840
+ this.fail(this.failure);
841
+ }
842
+ }
843
+ /** Whether the child's last line is the summary, with the fields it promises. */
844
+ function isDoneLine(value) {
845
+ if (typeof value !== 'object' || value === null)
846
+ return false;
847
+ const done = value;
848
+ return typeof done.recordsIn === 'number' && typeof done.rowsOut === 'number';
849
+ }
850
+ /**
851
+ * The sentence a stalled stream fails with.
852
+ *
853
+ * It states a **window** rather than a record, and the difference is the whole
854
+ * honesty of the message. A child killed with SIGKILL cannot report where it
855
+ * got to, so what the parent knows is the last `at` the child sent and the
856
+ * number of records it has been given since; the record that hung is somewhere
857
+ * between them. Naming `answered + 1` would read as a precise answer and would
858
+ * be wrong by up to {@link FLUSH_RECORDS} — and wrong in the direction that
859
+ * sends somebody to look at a row that transformed perfectly well.
860
+ *
861
+ * The window collapses to one record when it can, because "somewhere in records
862
+ * 618 to 618" is a sentence nobody should have to read.
863
+ *
864
+ * It also says what became of the rows, which is the second question anybody
865
+ * asks and the one #96 established the rule for: rows already yielded were
866
+ * already written by whoever consumed them, and they sit in an uncommitted
867
+ * snapshot because nothing on this path reaches a commit or moves a watermark.
868
+ */
869
+ function stallError(timeoutMs, answered, sent) {
870
+ const where = sent <= answered + 1 ? `on record ${sent}` : `somewhere in records ${answered + 1} to ${sent}`;
871
+ return new Error(`The transform stopped making progress for ${timeoutMs}ms and was stopped. It had finished ${answered} record(s) and been given ${sent}, so it stopped ${where}. Rows it had already produced were passed on and are in an uncommitted snapshot; nothing was committed and no watermark moved.`);
872
+ }
873
+ /** Write, and wait for the pipe to drain if it asked us to. */
874
+ function write(stream, text) {
875
+ if (stream.write(text))
876
+ return undefined;
877
+ return new Promise((resolve) => stream.once('drain', resolve));
878
+ }
401
879
  /**
402
880
  * What the interpreter is invoked with, and which harness it is handed.
403
881
  *
@@ -569,6 +1047,168 @@ function javascriptModuleHarness(moduleUrl) {
569
1047
  return `${JAVASCRIPT_PRELUDE}
570
1048
  try {
571
1049
  ${JAVASCRIPT_PAYLOAD}
1050
+ ${javascriptExportedFunction(moduleUrl)}
1051
+ const rows = await exported({ records, context });
1052
+ process.stdout.write(JSON.stringify({ rows: rows ?? [], logs: captured() }));
1053
+ } catch (error) {
1054
+ ${JAVASCRIPT_FAILURE}
1055
+ }
1056
+ `;
1057
+ }
1058
+ /**
1059
+ * The harness for `'record'` mode: import the author's module, call it once per
1060
+ * record, and put the rows on the wire as they are produced.
1061
+ *
1062
+ * Everything about the *code* is the same as {@link javascriptModuleHarness} —
1063
+ * the same six console channels, the same two caps, the same frozen `context`,
1064
+ * the same two accepted export spellings, the same refusal by name when there is
1065
+ * neither. What differs is only the argument and the transport, and keeping the
1066
+ * rest identical is what makes "the same transform, called differently" true
1067
+ * rather than approximately true.
1068
+ *
1069
+ * ## What a per-record transform can and cannot retain
1070
+ *
1071
+ * The question is worth answering exactly, because "it cannot see the batch" is
1072
+ * a claim about a contract and people will build on it.
1073
+ *
1074
+ * **It cannot see other records.** The function is handed one `record` and there
1075
+ * is no array anywhere in scope. That is enforced by the shape of the call, not
1076
+ * by convention.
1077
+ *
1078
+ * **It cannot emit at the end.** There is no finish hook, no flush callback, no
1079
+ * second export this harness looks for. A transform that accumulates into a
1080
+ * module-scope `Map` intending to return the totals afterwards has nowhere to
1081
+ * return them *to*, so it emits nothing and the node's row count is zero — which
1082
+ * is loud, immediate and visible on the run, rather than a partial aggregate
1083
+ * committed as though it were the answer. This is the enforcement that matters,
1084
+ * because it is the one that turns "you have used the wrong mode" from silently
1085
+ * wrong data into an obvious failure.
1086
+ *
1087
+ * **It cannot retain anything past the node.** The process is spawned for one
1088
+ * node run and killed at the end of it, so nothing carries to the next run, the
1089
+ * next node, or another connector sharing the same transform. That is enforced
1090
+ * by process lifetime and is the identical guarantee the whole-batch path has
1091
+ * always had — the child was never reused there either.
1092
+ *
1093
+ * **It can retain state in module scope for the length of one run**, and no
1094
+ * honest harness can stop it: a module may close over a `let`, and preventing
1095
+ * that means either re-importing per record (which would cost more than the
1096
+ * subprocess this change exists to make cheaper) or forbidding modules, which is
1097
+ * the only shape the mode accepts. So it is stated rather than pretended
1098
+ * otherwise. What that buys an author is a memo table or a compiled regex, which
1099
+ * is legitimate and useful; what it does not buy is an aggregate, for the reason
1100
+ * directly above. Records arrive in source order, so such a transform is
1101
+ * deterministic — it is simply not what the mode is for.
1102
+ *
1103
+ * ## The wire, and why the child talks in lines
1104
+ *
1105
+ * Records arrive as JSON arrays, one per line, {@link RECORDS_PER_LINE} at a
1106
+ * time; the first line is the context. Rows leave as `{"at":N,"rows":[…]}`
1107
+ * lines, then one `{"done":…}` or `{"failed":…}`. `at` is how many records have
1108
+ * been *consumed*, and it is on every line for two separate consumers: it is
1109
+ * what a failure names so a stack trace can be tied to a row, and it is what the
1110
+ * parent's stall clock resets on. See {@link FLUSH_BYTES} and
1111
+ * {@link FLUSH_RECORDS} for why a line is written even when it carries no rows.
1112
+ *
1113
+ * `process.stdout.write` is awaited through its `drain`, which is the child's
1114
+ * half of the back-pressure: a parent that stops reading stops this loop, which
1115
+ * stops it reading stdin, which stops the source. Without it the child would
1116
+ * happily buffer the whole output in its own heap while congratulating itself on
1117
+ * streaming.
1118
+ */
1119
+ function javascriptRecordHarness(moduleUrl) {
1120
+ return `${JAVASCRIPT_CONSOLE}
1121
+ let at = 0;
1122
+ let rowsOut = 0;
1123
+ let pending = [];
1124
+ let pendingBytes = 0;
1125
+ let sinceFlush = 0;
1126
+ const send = async (line) => {
1127
+ if (!process.stdout.write(line)) await new Promise((r) => process.stdout.once("drain", r));
1128
+ };
1129
+ const flush = async () => {
1130
+ const line = '{"at":' + at + ',"rows":[' + pending.join(",") + ']}\\n';
1131
+ pending = [];
1132
+ pendingBytes = 0;
1133
+ sinceFlush = 0;
1134
+ await send(line);
1135
+ };
1136
+ // One rule for four return shapes: an object is a row, an array is those rows,
1137
+ // and null or undefined is none. See CatalogRecordTransformFunction, which is
1138
+ // where the argument for that rule lives.
1139
+ const collect = (value) => {
1140
+ if (value === null || value === undefined) return;
1141
+ const rows = Array.isArray(value) ? value : [value];
1142
+ for (const row of rows) {
1143
+ const json = JSON.stringify(row);
1144
+ pending.push(json);
1145
+ pendingBytes += json.length;
1146
+ rowsOut += 1;
1147
+ }
1148
+ };
1149
+ try {
1150
+ ${javascriptExportedFunction(moduleUrl)}
1151
+ let context = null;
1152
+ let carry = "";
1153
+ process.stdin.setEncoding("utf8");
1154
+ for await (const chunk of process.stdin) {
1155
+ carry += chunk;
1156
+ let nl = carry.indexOf("\\n");
1157
+ while (nl !== -1) {
1158
+ const line = carry.slice(0, nl);
1159
+ carry = carry.slice(nl + 1);
1160
+ nl = carry.indexOf("\\n");
1161
+ if (line.length === 0) continue;
1162
+ if (context === null) {
1163
+ const sent = JSON.parse(line);
1164
+ // Frozen one level down, exactly as the batch harnesses freeze it, so
1165
+ // assigning to context.env.TOKEN fails loudly here rather than appearing
1166
+ // to work. Frozen ONCE, outside the record loop: it is the same object
1167
+ // for every record by construction, so there is nothing a transform
1168
+ // could write on it that would reach the next one.
1169
+ context = Object.freeze({ ...sent, env: Object.freeze({ ...sent.env }) });
1170
+ continue;
1171
+ }
1172
+ for (const record of JSON.parse(line)) {
1173
+ at += 1;
1174
+ sinceFlush += 1;
1175
+ collect(await exported({ record, context }));
1176
+ if (pendingBytes >= ${FLUSH_BYTES} || sinceFlush >= ${FLUSH_RECORDS}) await flush();
1177
+ }
1178
+ }
1179
+ }
1180
+ await flush();
1181
+ await send(JSON.stringify({ done: { recordsIn: at, rowsOut, logs: captured() } }) + "\\n");
1182
+ } catch (error) {
1183
+ // Whatever was pending is deliberately NOT flushed. The node has failed, so
1184
+ // nothing downstream will read this stage; sending the rows anyway would put
1185
+ // work on the wire that exists only to be discarded, and would make the last
1186
+ // "at" a reader sees larger than the record that actually threw.
1187
+ await send(JSON.stringify({
1188
+ failed: {
1189
+ at,
1190
+ error: error instanceof Error ? \`\${error.name}: \${error.message}\` : String(error),
1191
+ logs: captured(),
1192
+ },
1193
+ }) + "\\n");
1194
+ }
1195
+ `;
1196
+ }
1197
+ /**
1198
+ * Find the function the author exported, or refuse by name.
1199
+ *
1200
+ * Shared verbatim by the module harness and the record harness rather than
1201
+ * copied into each, for the reason {@link JAVASCRIPT_CONSOLE} is shared: two
1202
+ * copies of "which exports are accepted" are two answers, and the one that
1203
+ * drifts is discovered by an author whose perfectly good `export default` works
1204
+ * in one mode and not the other.
1205
+ *
1206
+ * The sentence it throws deliberately does not name a mode. Both modes accept
1207
+ * exactly the same two spellings, and what differs — the argument — is already
1208
+ * on the type the author is writing against.
1209
+ */
1210
+ function javascriptExportedFunction(moduleUrl) {
1211
+ return `
572
1212
  const mod = await import(${JSON.stringify(moduleUrl)});
573
1213
  const exported = typeof mod.default === "function"
574
1214
  ? mod.default
@@ -583,11 +1223,6 @@ try {
583
1223
  " Export the function as \`export default\`, or name it \`transform\`."
584
1224
  );
585
1225
  }
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
1226
  `;
592
1227
  }
593
1228
  /**
@@ -597,7 +1232,7 @@ try {
597
1232
  * copies of a log cap are two numbers that drift, and the one that drifts is
598
1233
  * discovered by a run record nobody can explain.
599
1234
  */
600
- const JAVASCRIPT_PRELUDE = `
1235
+ const JAVASCRIPT_CONSOLE = `
601
1236
  const logs = [];
602
1237
  let dropped = 0;
603
1238
  const keep = (line) => {
@@ -614,11 +1249,25 @@ console.debug = write; console.trace = write;
614
1249
  const captured = () => dropped === 0
615
1250
  ? logs
616
1251
  : logs.concat(["… " + dropped + " more line(s) were logged and dropped: a transform keeps its first ${MAX_LOG_LINES}."]);
617
-
1252
+ `;
1253
+ /**
1254
+ * The whole of stdin, as one string.
1255
+ *
1256
+ * Split out of the prelude when the streaming harness arrived, because that one
1257
+ * must **not** do this: slurping the input is precisely the thing a per-record
1258
+ * run exists to avoid, and a harness that inherited it by sharing a constant
1259
+ * would have held the dataset in the child while the parent was carefully not
1260
+ * holding it in itself. The console capture above is shared by all three
1261
+ * harnesses and this is shared by the two that read a finished batch, which is
1262
+ * the line the split follows.
1263
+ */
1264
+ const JAVASCRIPT_SLURP_STDIN = `
618
1265
  let input = "";
619
1266
  process.stdin.setEncoding("utf8");
620
1267
  for await (const chunk of process.stdin) input += chunk;
621
1268
  `;
1269
+ /** What the two whole-batch harnesses open with: capture the console, read stdin. */
1270
+ const JAVASCRIPT_PRELUDE = `${JAVASCRIPT_CONSOLE}${JAVASCRIPT_SLURP_STDIN}`;
622
1271
  /** Unpack the envelope. `context` is frozen one level down — see below. */
623
1272
  const JAVASCRIPT_PAYLOAD = `
624
1273
  const payload = JSON.parse(input || "{}");