tina4-nodejs 3.13.95 → 3.13.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CLAUDE.md +3 -4
  2. package/package.json +2 -1
  3. package/packages/cli/dist/bin.js +708 -1012
  4. package/packages/core/dist/index.js +588 -893
  5. package/packages/core/public/css/tina4.min.css +1 -1
  6. package/packages/core/src/index.ts +1 -3
  7. package/packages/core/src/messenger.ts +288 -96
  8. package/packages/core/src/queueBackends/kafkaBackend.ts +23 -2
  9. package/packages/core/src/queueBackends/rabbitmqBackend.ts +29 -17
  10. package/packages/core/src/request.ts +28 -7
  11. package/packages/core/src/server.ts +135 -7
  12. package/packages/core/src/session.ts +8 -1
  13. package/packages/orm/dist/index.js +639 -944
  14. package/packages/orm/src/autoCrud.ts +12 -10
  15. package/packages/orm/src/database.ts +62 -58
  16. package/packages/orm/src/databaseResult.ts +44 -73
  17. package/packages/orm/src/index.ts +0 -3
  18. package/packages/orm/src/migration.ts +26 -8
  19. package/packages/orm/src/model.ts +4 -0
  20. package/packages/orm/src/queryBuilder.ts +12 -5
  21. package/packages/orm/src/types.ts +7 -74
  22. package/packages/swagger/dist/index.js +78 -20
  23. package/packages/swagger/src/generator.ts +172 -29
  24. package/types/core/src/index.d.ts +1 -3
  25. package/types/core/src/messenger.d.ts +45 -4
  26. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -0
  27. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +2 -1
  28. package/types/core/src/server.d.ts +0 -4
  29. package/types/core/src/session.d.ts +7 -0
  30. package/types/orm/src/database.d.ts +34 -30
  31. package/types/orm/src/databaseResult.d.ts +26 -36
  32. package/types/orm/src/index.d.ts +1 -2
  33. package/types/orm/src/migration.d.ts +4 -3
  34. package/types/orm/src/types.d.ts +7 -34
  35. package/packages/core/src/scss.ts +0 -623
  36. package/types/core/src/scss.d.ts +0 -19
@@ -471,16 +471,8 @@ export class RabbitMQBackend implements QueueBackend {
471
471
  process.stdout.write(String(msgCount));
472
472
  closeConnection();
473
473
  }
474
- else if (operation === "purge") {
475
- // Queue.Purge
476
- const qBuf = Buffer.from(queueName, "utf-8");
477
- const purgePayload = Buffer.alloc(4 + qBuf.length);
478
- purgePayload.writeUInt16BE(0, 0);
479
- purgePayload.writeUInt8(qBuf.length, 2);
480
- qBuf.copy(purgePayload, 3);
481
- purgePayload.writeUInt8(0, 3 + qBuf.length); // no-wait=false
482
- sendMethod(1, 50, 30, purgePayload);
483
- }
474
+ // No "purge" operation: clear()/purge() refuse by name (ADR-0022),
475
+ // so nothing ever sends Queue.Purge and the drain path is gone.
484
476
  }
485
477
  else if (classId === 60 && methodId === 71) {
486
478
  // Basic.Get-Ok — message body will follow in content frames
@@ -491,11 +483,6 @@ export class RabbitMQBackend implements QueueBackend {
491
483
  process.stdout.write("__EMPTY__");
492
484
  closeConnection();
493
485
  }
494
- else if (classId === 50 && methodId === 31) {
495
- // Queue.Purge-Ok
496
- process.stdout.write("__PURGED__");
497
- closeConnection();
498
- }
499
486
  else if (classId === 10 && methodId === 50) {
500
487
  // Connection.Close (server-initiated, e.g. a channel/protocol error)
501
488
  // → send Connection.Close-Ok and exit non-zero so the caller sees the
@@ -633,7 +620,32 @@ export class RabbitMQBackend implements QueueBackend {
633
620
  return isNaN(num) ? 0 : num;
634
621
  }
635
622
 
636
- clear(queue: string): void {
637
- this.execSync("purge", queue);
623
+ clear(_queue: string): void {
624
+ // Not performable on RabbitMQ - throws naming the backend and the operation.
625
+ // clear() empties the queue, but RabbitMQ cannot address messages by status;
626
+ // the only thing it could do is queue.purge the WHOLE live queue. This used
627
+ // to do exactly that (execSync("purge", queue)), silently destroying every
628
+ // pending job. Draining a live broker on a status-addressed clear is data
629
+ // loss (ADR-0022 invariant 6), so it refuses by name instead. PHP, Python
630
+ // and Ruby already refuse; this brings the Node backend class in line.
631
+ throw new Error(
632
+ "The rabbitmq queue backend cannot perform clear(): RabbitMQ cannot " +
633
+ "address messages by status (basic.get pops the head of the queue), so " +
634
+ "a status-addressed clear would have to drain the entire live queue and " +
635
+ "destroy pending work. Use the file or mongodb backend.",
636
+ );
637
+ }
638
+
639
+ purge(_queue: string, _status?: string): number {
640
+ // Not performable on RabbitMQ - throws naming the backend and the operation.
641
+ // purge(status) removes jobs SELECTED BY STATUS; RabbitMQ has no status
642
+ // concept and could only drain the whole live queue. Refusing by name is
643
+ // the honest answer.
644
+ throw new Error(
645
+ "The rabbitmq queue backend cannot perform purge(): RabbitMQ cannot " +
646
+ "address messages by status (basic.get pops the head of the queue), so " +
647
+ "a status-addressed purge would have to drain the entire live queue and " +
648
+ "destroy pending work. Use the file or mongodb backend.",
649
+ );
638
650
  }
639
651
  }
@@ -160,19 +160,40 @@ async function parseBody(req: Tina4Request): Promise<void> {
160
160
  const chunks: Buffer[] = [];
161
161
 
162
162
  await new Promise<void>((resolve, reject) => {
163
- req.on("data", (chunk: Buffer) => chunks.push(chunk));
164
- req.on("end", resolve);
163
+ // A RUNNING cap, checked per chunk.
164
+ //
165
+ // The content-length check above only sees what the client DECLARES. A
166
+ // chunked request declares nothing, so declaredLength is 0 and it sails
167
+ // through. Without this counter the body is buffered in full and only
168
+ // then measured, which means the limit cannot prevent the thing it exists
169
+ // to prevent. Measured against a 1MB limit: a 40MB chunked POST with no
170
+ // content-length was accepted whole and grew the server's RSS by exactly
171
+ // 40.0MB before it was refused.
172
+ //
173
+ // Same defect as PHP's unbounded read buffer, and the same fix: stop at
174
+ // the limit instead of measuring the damage afterwards.
175
+ let received = 0;
176
+ let refused = false;
177
+ req.on("data", (chunk: Buffer) => {
178
+ if (refused) return;
179
+ received += chunk.length;
180
+ if (received > TINA4_MAX_UPLOAD_SIZE) {
181
+ refused = true;
182
+ chunks.length = 0; // drop what we have; the request is dead
183
+ reject(new PayloadTooLargeError(received, TINA4_MAX_UPLOAD_SIZE));
184
+ return;
185
+ }
186
+ chunks.push(chunk);
187
+ });
188
+ req.on("end", () => {
189
+ if (!refused) resolve();
190
+ });
165
191
  req.on("error", reject);
166
192
  });
167
193
 
168
194
  const raw = Buffer.concat(chunks);
169
195
  if (raw.length === 0) return;
170
196
 
171
- // Check actual body size against upload size limit
172
- if (raw.length > TINA4_MAX_UPLOAD_SIZE) {
173
- throw new PayloadTooLargeError(raw.length, TINA4_MAX_UPLOAD_SIZE);
174
- }
175
-
176
197
  if (contentType.includes("multipart/form-data")) {
177
198
  const boundary = extractBoundary(contentType);
178
199
  if (boundary) {
@@ -378,9 +378,29 @@ function openBrowser(url: string) {
378
378
  * for backwards compatibility.
379
379
  */
380
380
  export function resolvePortAndHost(config?: { port?: number; host?: string }): { port: number; host: string } {
381
- const port = config?.port
382
- ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined)
383
- ?? 7148;
381
+ // Port: explicit config > TINA4_PORT > PORT (deprecated) > default.
382
+ //
383
+ // This read PORT and nothing else, so TINA4_PORT - the name the CLI
384
+ // documents and prefers, and the one devAdmin.ts itself reads first - was
385
+ // IGNORED on the path that binds the socket. Setting it did nothing and said
386
+ // nothing.
387
+ //
388
+ // Bare PORT stays honoured so no deployment breaks, and warns so the
389
+ // migration happens. Removal is 3.14.
390
+ const tina4Port = process.env.TINA4_PORT;
391
+ const legacyPort = process.env.PORT;
392
+ let port: number;
393
+ if (config?.port !== undefined) {
394
+ port = config.port;
395
+ } else if (tina4Port && /^\d+$/.test(tina4Port)) {
396
+ port = parseInt(tina4Port, 10);
397
+ } else if (legacyPort && /^\d+$/.test(legacyPort)) {
398
+ port = parseInt(legacyPort, 10);
399
+ warnDeprecatedPort(port);
400
+ } else {
401
+ port = 7148;
402
+ }
403
+
384
404
  const host = config?.host
385
405
  ?? process.env.TINA4_HOST
386
406
  ?? process.env.HOST
@@ -388,6 +408,22 @@ export function resolvePortAndHost(config?: { port?: number; host?: string }): {
388
408
  return { port, host };
389
409
  }
390
410
 
411
+ /**
412
+ * Warn ONCE that bare PORT was used instead of TINA4_PORT.
413
+ *
414
+ * Once, because resolvePortAndHost can be called more than once per process
415
+ * and a warning repeated on every call is a warning people filter out.
416
+ */
417
+ let portDeprecationWarned = false;
418
+ function warnDeprecatedPort(port: number): void {
419
+ if (portDeprecationWarned) return;
420
+ portDeprecationWarned = true;
421
+ Log.warning(
422
+ `PORT is deprecated and will be removed in 3.14 - use TINA4_PORT instead ` +
423
+ `(binding port ${port} from PORT)`,
424
+ );
425
+ }
426
+
391
427
  /**
392
428
  * Whether the boot banner should be suppressed. Set TINA4_SUPPRESS=true to
393
429
  * silence the ASCII-art banner and route table on startup — useful in CI,
@@ -795,6 +831,67 @@ let _serverHandle: { close: () => void; router: Router; port: number } | null =
795
831
  * Start the Tina4 HTTP server.
796
832
  * Thin wrapper around startServer() for cross-framework parity with PHP and Ruby.
797
833
  */
834
+ /**
835
+ * Watch for a handler that occupies the event loop, and say so.
836
+ *
837
+ * Node runs ONE loop. An `await`ing handler yields it and blocks nobody -
838
+ * measured, /fast answers in 0.030s while a route awaits a 2s timer. A
839
+ * CPU-BOUND handler does not yield, and everything else waits: the same /fast
840
+ * took 1.575s during a 2s busy loop.
841
+ *
842
+ * That is inherent to a single-loop runtime, not a bug to engineer away. PHP
843
+ * fixed its equivalent by forking per request because `sleep()` is the obvious
844
+ * thing to write there and it blocks; in JavaScript the obvious thing is
845
+ * `await`, which does not. So the exposure here is narrower - CPU-bound work
846
+ * and synchronous I/O - and the honest fix is to make it VISIBLE rather than
847
+ * to move handlers onto threads a closure cannot cross.
848
+ *
849
+ * The mechanism is loop lag: a timer set for TICK_MS fires late by however
850
+ * long the loop was blocked. If that lateness passes the threshold, something
851
+ * held the loop and the developer wants to know which.
852
+ *
853
+ * A 100ms repeating timer is the classic way to pin a process open forever, so
854
+ * there are two guards against it: close() stops the timer, and the timer is
855
+ * unref'd. Measured: either one alone is enough, and the signal path exits
856
+ * regardless of both. They are kept together because they cost nothing and
857
+ * cover different exits - close() covers the in-process handle, unref() covers
858
+ * a path that never reaches close() at all.
859
+ */
860
+ const LOOP_WATCHDOG_TICK_MS = 100;
861
+
862
+ function startLoopWatchdog(): { stop: () => void } {
863
+ const raw = (process.env.TINA4_LOOP_LAG_WARN_MS ?? "").trim();
864
+ // 0 or a negative value disables it; a non-numeric value falls to the
865
+ // default rather than silently disabling a diagnostic.
866
+ const threshold = /^\d+$/.test(raw) ? parseInt(raw, 10) : 250;
867
+ if (threshold <= 0) {
868
+ return { stop: () => {} };
869
+ }
870
+
871
+ let last = Date.now();
872
+ let warned = 0;
873
+ const timer = setInterval(() => {
874
+ const now = Date.now();
875
+ const lag = now - last - LOOP_WATCHDOG_TICK_MS;
876
+ last = now;
877
+ if (lag < threshold) return;
878
+
879
+ // Rate-limited: a handler that blocks on every request would otherwise
880
+ // produce a wall of identical warnings, which people filter out.
881
+ warned++;
882
+ if (warned > 5 && warned % 20 !== 0) return;
883
+ Log.warning(
884
+ `Event loop blocked for ${lag}ms. Node serves every request on one loop, ` +
885
+ `so a handler doing CPU-bound work or synchronous I/O stalls all the ` +
886
+ `others for that long. Move the work to Tina4's queue, or await it. ` +
887
+ `Set TINA4_LOOP_LAG_WARN_MS to change the ${threshold}ms threshold, or 0 to silence.`,
888
+ );
889
+ }, LOOP_WATCHDOG_TICK_MS);
890
+ timer.unref();
891
+
892
+ return { stop: () => clearInterval(timer) };
893
+ }
894
+
798
895
  export async function start(config?: Tina4Config): Promise<{ close: () => void; router: Router; port: number }> {
799
896
  const isManaged = process.argv.includes('--managed');
800
897
  if (!isManaged && process.env.TINA4_OVERRIDE_CLIENT !== 'true') {
@@ -1390,8 +1487,18 @@ export async function startServer(config?: Tina4Config): Promise<{
1390
1487
  const host = resolved.host;
1391
1488
  let port = resolved.port;
1392
1489
 
1393
- // Claim the requested port — kill whatever is on it if needed
1394
- port = findAvailablePort(port);
1490
+ // Claim the requested port — kill whatever is on it if needed.
1491
+ //
1492
+ // NOT in a cluster worker. A worker does not own the port: the primary binds
1493
+ // it once and hands the handle down through cluster's IPC. A worker running
1494
+ // this finds the port "in use" (the primary is holding it) and KILLS the
1495
+ // process holding it, which is its own parent. Every worker did that, then
1496
+ // died itself with `write EPIPE` from cluster._getServer because the primary
1497
+ // it needed to ask for the socket was gone. Cluster mode never served a
1498
+ // single request.
1499
+ if (!cluster.isWorker) {
1500
+ port = findAvailablePort(port);
1501
+ }
1395
1502
 
1396
1503
  // Cluster mode for production: fork workers based on CPU count
1397
1504
  // Only when --production is explicitly set (via TINA4_PRODUCTION env var)
@@ -1703,8 +1810,26 @@ ${reset}
1703
1810
  await middleware.run(req, res);
1704
1811
  if (res.raw.writableEnded) return;
1705
1812
 
1706
- // Parse request body
1707
- await req.parseBody();
1813
+ // Parse request body.
1814
+ //
1815
+ // A body that breaks a documented limit is the client's error, not the
1816
+ // server's. PayloadTooLargeError already carried `statusCode = 413` and
1817
+ // nothing read it, so an oversized upload answered 500 - which tells the
1818
+ // caller to retry the exact request that will fail again.
1819
+ try {
1820
+ await req.parseBody();
1821
+ } catch (err) {
1822
+ const status = (err as { statusCode?: number })?.statusCode;
1823
+ if (typeof status === "number" && status >= 400 && status < 500) {
1824
+ if (!rawRes.writableEnded) {
1825
+ rawRes.statusCode = status;
1826
+ rawRes.setHeader("content-type", "application/json");
1827
+ rawRes.end(JSON.stringify({ error: (err as Error).message }));
1828
+ }
1829
+ return;
1830
+ }
1831
+ throw err;
1832
+ }
1708
1833
 
1709
1834
  const pathname = req.path;
1710
1835
 
@@ -2043,8 +2168,11 @@ ${reset}
2043
2168
  process.on("SIGTERM", onSigterm);
2044
2169
  process.on("SIGINT", onSigint);
2045
2170
 
2171
+ const loopWatchdog = startLoopWatchdog();
2172
+
2046
2173
  resolvePromise({
2047
2174
  close: () => {
2175
+ loopWatchdog.stop();
2048
2176
  // An explicit close() is not a signal shutdown: drop the handlers so
2049
2177
  // a test that starts many servers in one process does not pile up
2050
2178
  // listeners (and trip Node's MaxListeners warning).
@@ -711,10 +711,17 @@ export class Session {
711
711
  *
712
712
  * session.flash("message", "Saved!") // set
713
713
  * session.flash("message") // get + auto-remove → "Saved!"
714
+ * session.flash("message", null) // get + auto-remove (null is a GET sentinel)
715
+ *
716
+ * `null` — NOT just `undefined` — is the GET sentinel, so `flash(key, null)`
717
+ * READS and clears rather than STORING null. This matches the Python master
718
+ * (`if value is not None`), PHP (`if ($value !== null)`) and Ruby
719
+ * (`if value.nil?`): passing the language's "no value" literal means GET. A
720
+ * caller wanting to persist an explicit null should store it with `set()`.
714
721
  */
715
722
  flash(key: string, value?: unknown): unknown {
716
723
  const flashKey = `${FLASH_PREFIX}${key}`;
717
- if (value !== undefined) {
724
+ if (value !== undefined && value !== null) {
718
725
  // Set mode
719
726
  this.set(flashKey, value);
720
727
  return undefined;