tina4-nodejs 3.13.37 → 3.13.39

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 (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +66 -44
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. package/packages/swagger/src/ui.ts +1 -1
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { Log } from "@tina4/core";
3
4
  import type { FieldDefinition, DatabaseAdapter } from "./types.js";
4
5
  import type { SQLiteAdapter } from "./adapters/sqlite.js";
5
6
  import type { DiscoveredModel } from "./model.js";
@@ -46,10 +47,10 @@ async function firebirdColumnExists(
46
47
  table: string,
47
48
  column: string,
48
49
  ): Promise<boolean> {
49
- const rows = await (db as any).queryAsync<Record<string, unknown>>(
50
+ const rows = (await (db as any).queryAsync(
50
51
  "SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND TRIM(RDB$FIELD_NAME) = ?",
51
52
  [table.toUpperCase(), column.toUpperCase()],
52
- );
53
+ )) as unknown[];
53
54
  return rows.length > 0;
54
55
  }
55
56
 
@@ -76,6 +77,55 @@ async function shouldSkipForFirebird(
76
77
  return null;
77
78
  }
78
79
 
80
+ /**
81
+ * Match CREATE TABLE <name> — name may be quoted ("x"), bracketed ([x] MSSQL),
82
+ * or bare. Captures the table name.
83
+ */
84
+ const CREATE_TABLE_RE =
85
+ /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|\[([^\]]+)\]|(\w+))/i;
86
+
87
+ /**
88
+ * Identify the database engine via the adapter's class name.
89
+ * (constructor.name is the engine discriminator used throughout this package.)
90
+ */
91
+ function engineOf(db: DatabaseAdapter): "firebird" | "mssql" | "other" {
92
+ const name = db.constructor.name;
93
+ if (name === "FirebirdAdapter") return "firebird";
94
+ if (name === "MssqlAdapter") return "mssql";
95
+ return "other";
96
+ }
97
+
98
+ /**
99
+ * Make CREATE TABLE idempotent on engines lacking IF NOT EXISTS.
100
+ *
101
+ * Firebird and MSSQL do not support `CREATE TABLE IF NOT EXISTS`, so a raw
102
+ * CREATE in a re-run migration raises "object already exists". When the target
103
+ * table already exists on those engines, return a skip reason so the statement
104
+ * is skipped (mirrors the Firebird ALTER-TABLE-ADD idempotency guard).
105
+ * SQLite/MySQL/PostgreSQL support IF NOT EXISTS and are left to the engine.
106
+ * Only a genuine already-exists is skipped — every other error still raises.
107
+ */
108
+ export async function shouldSkipCreateTable(
109
+ db: DatabaseAdapter,
110
+ stmt: string,
111
+ ): Promise<string | null> {
112
+ const engine = engineOf(db);
113
+ if (engine !== "firebird" && engine !== "mssql") return null;
114
+
115
+ const m = stmt.match(CREATE_TABLE_RE);
116
+ if (!m) return null;
117
+
118
+ const table = m[1] ?? m[2] ?? m[3];
119
+ try {
120
+ if (await adapterTableExists(db, table)) {
121
+ return `Table ${table} already exists, skipping CREATE TABLE`;
122
+ }
123
+ } catch {
124
+ return null;
125
+ }
126
+ return null;
127
+ }
128
+
79
129
  /**
80
130
  * Sync model definitions to the database (create tables, add columns).
81
131
  */
@@ -411,13 +461,44 @@ export interface MigrationStatus {
411
461
  pending: string[];
412
462
  }
413
463
 
464
+ /**
465
+ * Smart/curly quotes — editors, word processors, docs and chat apps silently
466
+ * convert a straight " to “ ” and a straight ' to ‘ ’ (plus primes ′ ″). Those
467
+ * characters are NOT valid SQL string/identifier delimiters, so a pasted-in
468
+ * migration fails to run ("syntax error near …"). Map them back to straight
469
+ * ASCII quotes. (Real string CONTENTS are unaffected by intent — we only swap
470
+ * the lookalike code points for their ASCII equivalents.)
471
+ */
472
+ const SMART_QUOTES: Record<string, string> = {
473
+ // Double-quote lookalikes → straight " (U+0022)
474
+ "“": '"', "”": '"', "„": '"', "‟": '"', "″": '"',
475
+ // Single-quote / apostrophe lookalikes → straight ' (U+0027)
476
+ "‘": "'", "’": "'", "‚": "'", "‛": "'", "′": "'",
477
+ };
478
+ const SMART_QUOTE_RE = new RegExp(`[${Object.keys(SMART_QUOTES).join("")}]`, "g");
479
+
480
+ /**
481
+ * Replace smart/curly quotes with straight ASCII quotes so migration SQL
482
+ * authored or pasted from an editor/doc actually runs (those code points are
483
+ * not valid SQL delimiters). Already-straight quotes and ordinary string
484
+ * content are returned byte-for-byte unchanged.
485
+ */
486
+ export function normalizeQuotes(sql: string): string {
487
+ return sql.replace(SMART_QUOTE_RE, (ch) => SMART_QUOTES[ch]);
488
+ }
489
+
414
490
  /**
415
491
  * Split SQL text into individual statements on the given delimiter.
416
492
  *
417
493
  * Strips line comments (`-- ...`) and block comments, handles stored
418
494
  * procedure blocks delimited by `$$` or `//`.
419
495
  */
420
- function splitStatements(sql: string, delimiter = ";"): string[] {
496
+ export function splitStatements(sql: string, delimiter = ";"): string[] {
497
+ // Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
498
+ // an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs. Mirrors
499
+ // Python's _split_statements applying _normalize_quotes as its first line.
500
+ sql = normalizeQuotes(sql);
501
+
421
502
  // Extract blocks delimited by $$ or // first, replacing with placeholders
422
503
  const blocks: string[] = [];
423
504
  const saveBlock = (_match: string, _p1: string): string => {
@@ -426,7 +507,12 @@ function splitStatements(sql: string, delimiter = ";"): string[] {
426
507
  };
427
508
 
428
509
  let processed = sql.replace(/\$\$([\s\S]*?)\$\$/g, saveBlock);
429
- processed = processed.replace(/\/\/([\s\S]*?)\/\//g, saveBlock);
510
+ // The `//` delimiters must NOT be preceded by a colon, so a URL scheme
511
+ // (`https://…`) or other `://` literal inside a migration is never captured
512
+ // as an opaque stored-proc block (it would otherwise swallow everything
513
+ // between two `//` occurrences and skip statement splitting/cleaning).
514
+ // Negative lookbehind `(?<!:)` mirrors Python's runner.
515
+ processed = processed.replace(/(?<!:)\/\/([\s\S]*?)(?<!:)\/\//g, saveBlock);
430
516
 
431
517
  // Remove block comments (/* ... */)
432
518
  const clean = processed.replace(/\/\*[\s\S]*?\*\//g, "");
@@ -458,25 +544,43 @@ function splitStatements(sql: string, delimiter = ";"): string[] {
458
544
  * - Sequential: 000001_name.sql, 000002_name.sql
459
545
  * - Timestamp: 20240315120000_name.sql (YYYYMMDDHHMMSS)
460
546
  *
461
- * Both patterns start with digits followed by underscore, so alphabetical
462
- * sort works correctly for both (zero-padded sequential and timestamp).
547
+ * Numeric-aware: a file with a leading numeric/timestamp prefix sorts first by
548
+ * that number (so `9_*` applies before `10_*` a plain lexical sort misorders
549
+ * unpadded prefixes because "10" < "9"). Files with NO numeric prefix sort
550
+ * AFTER the numbered ones, then lexically. Mirrors Python's `_migration_sort_key`.
463
551
  */
464
- function sortMigrationFiles(files: string[]): string[] {
552
+ export function sortMigrationFiles(files: string[]): string[] {
553
+ // Returns a tuple-like key: [group, numeric, name].
554
+ // group 0 = has numeric prefix (sorts first); group 1 = no prefix (sorts after).
555
+ const key = (name: string): [number, bigint, string] => {
556
+ const m = name.match(/^(\d+)/);
557
+ return m ? [0, BigInt(m[1]), name] : [1, 0n, name];
558
+ };
465
559
  return [...files].sort((a, b) => {
466
- const aPrefix = a.match(/^(\d+)/);
467
- const bPrefix = b.match(/^(\d+)/);
468
- if (aPrefix && bPrefix) {
469
- // Compare numeric prefixes handles both 000001 and 20240315120000
470
- const aNum = BigInt(aPrefix[1]);
471
- const bNum = BigInt(bPrefix[1]);
472
- if (aNum < bNum) return -1;
473
- if (aNum > bNum) return 1;
474
- return a.localeCompare(b);
475
- }
476
- return a.localeCompare(b);
560
+ const [ag, an, anm] = key(a);
561
+ const [bg, bn, bnm] = key(b);
562
+ if (ag !== bg) return ag - bg;
563
+ if (an < bn) return -1;
564
+ if (an > bn) return 1;
565
+ return anm.localeCompare(bnm);
477
566
  });
478
567
  }
479
568
 
569
+ /**
570
+ * Warn (once) about migration filenames without a recognized NNNNNN_/timestamp
571
+ * prefix — their ordering relative to numbered migrations is undefined, a silent
572
+ * out-of-order-apply footgun. Mirrors Python's runner.
573
+ */
574
+ function warnUnprefixedMigrations(files: string[]): void {
575
+ const unprefixed = files.filter((f) => !/^\d+[_-]/.test(f));
576
+ if (unprefixed.length > 0) {
577
+ Log.warning(
578
+ "Migration file(s) without a numeric/timestamp prefix may apply out of order: " +
579
+ unprefixed.join(", "),
580
+ );
581
+ }
582
+ }
583
+
480
584
  /**
481
585
  * Run all pending SQL-file migrations.
482
586
  *
@@ -533,7 +637,19 @@ export async function migrate(
533
637
  batch INTEGER NOT NULL DEFAULT 1,
534
638
  applied_at TEXT NOT NULL
535
639
  )`);
640
+ } else if (engineOf(db) === "mssql") {
641
+ // MSSQL has no AUTOINCREMENT / IF NOT EXISTS — route the bootstrap
642
+ // through the adapter's engine-aware createTable (IDENTITY(1,1) etc.),
643
+ // exactly like ensureMigrationTable does. Emitting raw
644
+ // AUTOINCREMENT/IF NOT EXISTS here is invalid on SQL Server.
645
+ await adapterCreateTable(db, MIGRATION_TABLE, {
646
+ id: { type: "integer", primaryKey: true, autoIncrement: true },
647
+ name: { type: "string", required: true },
648
+ batch: { type: "integer", required: true },
649
+ applied_at: { type: "datetime", default: "now" },
650
+ });
536
651
  } else {
652
+ // SQLite / MySQL — both support IF NOT EXISTS + AUTOINCREMENT/AUTO_INCREMENT.
537
653
  await adapterExecute(db, `CREATE TABLE IF NOT EXISTS "${MIGRATION_TABLE}" (
538
654
  id INTEGER PRIMARY KEY AUTOINCREMENT,
539
655
  name TEXT NOT NULL,
@@ -553,13 +669,17 @@ export async function migrate(
553
669
  }
554
670
  }
555
671
 
556
- // Collect .sql files (exclude .down.sql), sorted by prefix
672
+ // Collect .sql files (exclude .down.sql), numeric-aware sorted (9_ before 10_).
557
673
  const files = sortMigrationFiles(
558
674
  readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")),
559
675
  );
560
676
 
561
677
  if (files.length === 0) return result;
562
678
 
679
+ // Warn about files without a recognized numeric/timestamp prefix — their
680
+ // ordering relative to numbered migrations is undefined.
681
+ warnUnprefixedMigrations(files);
682
+
563
683
  // Determine the batch number for this run
564
684
  let currentBatch = 1;
565
685
  try {
@@ -621,10 +741,12 @@ export async function migrate(
621
741
  await adapterStartTransaction(db);
622
742
 
623
743
  for (const stmt of statements) {
624
- // Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
625
- // Pre-check the system catalogue so duplicate columns are
626
- // silently skipped instead of raising an error.
627
- const skipReason = await shouldSkipForFirebird(db, stmt);
744
+ // Idempotency on engines lacking IF NOT EXISTS: Firebird ALTER-TABLE-ADD,
745
+ // and CREATE TABLE on Firebird/MSSQL. Pre-check the catalogue so a genuine
746
+ // already-exists is skipped instead of raising every other error still
747
+ // raises (rolls the file back).
748
+ const skipReason =
749
+ (await shouldSkipForFirebird(db, stmt)) ?? (await shouldSkipCreateTable(db, stmt));
628
750
  if (skipReason) {
629
751
  console.log(` Migration ${file}: ${skipReason}`);
630
752
  continue;
@@ -671,7 +793,12 @@ export async function migrate(
671
793
  const msg = err instanceof Error ? err.message : String(err);
672
794
  console.error(` Migration failed: ${file} — ${msg}`);
673
795
  result.failed.push(file);
674
- // Continue to next file (matching Python behaviour)
796
+ // STOP at the first failure (parity with Python/PHP/Ruby). The file rolled
797
+ // back; later migrations must NOT be applied on top of a missing earlier
798
+ // one (a missing column / table would cascade silent corruption). Already-
799
+ // applied files stay applied — fix the bad file and re-run.
800
+ Log.error(`Migration stopped at first failure: ${file} — ${msg}`);
801
+ break;
675
802
  }
676
803
  }
677
804
 
@@ -428,8 +428,20 @@ export class QueryBuilder {
428
428
  return [{ [field]: { [mongoOp]: val } }, paramIndex + 1];
429
429
  }
430
430
 
431
- // Fallback
432
- return [{ $where: cond }, paramIndex];
431
+ // Canonical #5: no silent $where fallback. Previously an unparseable
432
+ // condition was wrapped as `{ $where: <raw condition string> }` — a raw-JS
433
+ // sink that is both injection-shaped (the WHERE string runs as JavaScript
434
+ // on the MongoDB server) and silently different semantics from the SQL the
435
+ // caller wrote. Fail loud instead: name the clause so the caller fixes it
436
+ // rather than shipping a surprise $where.
437
+ throw new Error(
438
+ `QueryBuilder.toMongo(): cannot translate WHERE clause to a MongoDB ` +
439
+ `filter: "${cond}". Supported forms: "<field> <op> ?" ` +
440
+ `(=, !=, <>, >, >=, <, <=), "<field> LIKE ?", ` +
441
+ `"<field> [NOT] IN (?)", "<field> IS [NOT] NULL". Rewrite the ` +
442
+ `condition in one of those forms (toMongo() will not silently emit a ` +
443
+ `raw $where JavaScript expression).`,
444
+ );
433
445
  }
434
446
 
435
447
  /**