tina4-nodejs 3.13.97 → 3.13.99

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 (96) hide show
  1. package/CLAUDE.md +60 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20620 -18995
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20459 -18815
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +28 -12
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +31 -13
  37. package/packages/frond/src/engine.ts +39 -7
  38. package/packages/orm/dist/index.js +10879 -9258
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/api.d.ts +11 -4
  63. package/types/core/src/background.d.ts +5 -2
  64. package/types/core/src/devAdmin.d.ts +35 -0
  65. package/types/core/src/dispatchPipeline.d.ts +41 -1
  66. package/types/core/src/errorOverlay.d.ts +13 -13
  67. package/types/core/src/index.d.ts +9 -6
  68. package/types/core/src/logger.d.ts +111 -185
  69. package/types/core/src/middleware.d.ts +40 -5
  70. package/types/core/src/portTakeover.d.ts +50 -0
  71. package/types/core/src/request.d.ts +15 -0
  72. package/types/core/src/response.d.ts +29 -0
  73. package/types/core/src/server.d.ts +92 -0
  74. package/types/core/src/testClient.d.ts +29 -3
  75. package/types/core/src/testing.d.ts +16 -12
  76. package/types/core/src/types.d.ts +21 -9
  77. package/types/core/src/version.d.ts +11 -0
  78. package/types/core/src/websocketBackplane.d.ts +1 -1
  79. package/types/frond/src/engine.d.ts +10 -0
  80. package/types/orm/src/adapters/firebird.d.ts +61 -2
  81. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  82. package/types/orm/src/adapters/mssql.d.ts +11 -0
  83. package/types/orm/src/adapters/mysql.d.ts +11 -0
  84. package/types/orm/src/adapters/odbc.d.ts +35 -4
  85. package/types/orm/src/adapters/postgres.d.ts +11 -0
  86. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  87. package/types/orm/src/baseModel.d.ts +45 -25
  88. package/types/orm/src/cachedDatabase.d.ts +27 -1
  89. package/types/orm/src/database.d.ts +56 -6
  90. package/types/orm/src/index.d.ts +3 -2
  91. package/types/orm/src/migration.d.ts +23 -5
  92. package/types/orm/src/query.d.ts +3 -0
  93. package/types/orm/src/seeder.d.ts +15 -2
  94. package/types/orm/src/sqlTranslator.d.ts +17 -4
  95. package/types/orm/src/types.d.ts +75 -16
  96. package/packages/core/src/errorOverlay.test.ts +0 -122
@@ -83,8 +83,12 @@ async function firebirdColumnExists(
83
83
  /**
84
84
  * If stmt is an ALTER TABLE ... ADD on Firebird and the column already exists,
85
85
  * returns a skip reason string. Returns null if the statement should execute normally.
86
+ *
87
+ * Exported (like its shouldSkipCreateTable sibling) so it can be driven
88
+ * directly against a REAL Firebird connection in
89
+ * test/migrationContract.test.ts -- no fake adapter needed.
86
90
  */
87
- async function shouldSkipForFirebird(
91
+ export async function shouldSkipForFirebird(
88
92
  db: DatabaseAdapter,
89
93
  stmt: string,
90
94
  ): Promise<string | null> {
@@ -278,6 +282,24 @@ function buildAddColumnSql(
278
282
  */
279
283
  const MIGRATION_TABLE = "tina4_migration";
280
284
 
285
+ /**
286
+ * The tracking-table identifier, quoted for the CALLING adapter's engine.
287
+ *
288
+ * SQLite, PostgreSQL, MSSQL and Firebird all accept ANSI double-quoted
289
+ * identifiers, but MySQL's default sql_mode (no ANSI_QUOTES — the stock
290
+ * `mysql:8` image never sets it) parses `"..."` as a STRING LITERAL, not an
291
+ * identifier. `CREATE TABLE "tina4_migration" (...)` on a database where the
292
+ * table does not already exist fails: "You have an error in your SQL syntax
293
+ * ... near '"tina4_migration" ('" — MEASURED against a real fresh MySQL 8
294
+ * container (a long-lived dev database that already has the table masks this
295
+ * completely, which is exactly why it only ever surfaced on CI's ephemeral
296
+ * one). Backticks are MySQL's identifier quote and are accepted in every
297
+ * sql_mode, so they are always correct there.
298
+ */
299
+ function mt(db: DatabaseAdapter): string {
300
+ return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
301
+ }
302
+
281
303
  /**
282
304
  * Derive a human-readable description from a migration name, matching the
283
305
  * Python master: strip a leading numeric/timestamp prefix and turn `_` into
@@ -291,8 +313,8 @@ function deriveDescription(name: string): string {
291
313
  * Build an `ALTER TABLE ... ADD` statement for the tracking table. Firebird
292
314
  * uses `ADD <col>` (no COLUMN keyword); every other engine uses `ADD COLUMN`.
293
315
  */
294
- function migrationAddColumnSql(fb: boolean, col: string, type: string, extra = ""): string {
295
- return `ALTER TABLE "${MIGRATION_TABLE}" ADD ${fb ? "" : "COLUMN "}${col} ${type}${extra}`;
316
+ function migrationAddColumnSql(db: DatabaseAdapter, col: string, type: string, extra = ""): string {
317
+ return `ALTER TABLE ${mt(db)} ADD ${isFirebirdAdapter(db) ? "" : "COLUMN "}${col} ${type}${extra}`;
296
318
  }
297
319
 
298
320
  /**
@@ -321,7 +343,7 @@ async function ensureMigrationTableOn(db: DatabaseAdapter): Promise<void> {
321
343
  } catch {
322
344
  // Generator may already exist
323
345
  }
324
- await adapterExecute(db, `CREATE TABLE "${MIGRATION_TABLE}" (
346
+ await adapterExecute(db, `CREATE TABLE ${mt(db)} (
325
347
  id INTEGER NOT NULL PRIMARY KEY,
326
348
  migration_name VARCHAR(500) NOT NULL UNIQUE,
327
349
  description VARCHAR(500),
@@ -340,7 +362,7 @@ async function ensureMigrationTableOn(db: DatabaseAdapter): Promise<void> {
340
362
  const idCol = migrationIdColumn(db);
341
363
  const engine = engineOf(db);
342
364
  const ifNotExists = engine === "mssql" ? "" : "IF NOT EXISTS ";
343
- await adapterExecute(db, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
365
+ await adapterExecute(db, `CREATE TABLE ${ifNotExists}${mt(db)} (
344
366
  ${idCol},
345
367
  migration_name VARCHAR(500) NOT NULL UNIQUE,
346
368
  description VARCHAR(500),
@@ -387,17 +409,17 @@ async function upgradeMigrationTable(db: DatabaseAdapter): Promise<void> {
387
409
  try { await adapterExecute(db, sql); } catch { /* column may already exist */ }
388
410
  };
389
411
 
390
- await tryExec(migrationAddColumnSql(fb, "migration_name", nameType));
391
- if (!cols.has("description")) await tryExec(migrationAddColumnSql(fb, "description", nameType));
392
- if (!cols.has("passed")) await tryExec(migrationAddColumnSql(fb, "passed", "INTEGER", " DEFAULT 1"));
393
- if (!cols.has("executed_at")) await tryExec(migrationAddColumnSql(fb, "executed_at", tsType));
394
- if (!cols.has("batch")) await tryExec(migrationAddColumnSql(fb, "batch", "INTEGER", " DEFAULT 1"));
412
+ await tryExec(migrationAddColumnSql(db, "migration_name", nameType));
413
+ if (!cols.has("description")) await tryExec(migrationAddColumnSql(db, "description", nameType));
414
+ if (!cols.has("passed")) await tryExec(migrationAddColumnSql(db, "passed", "INTEGER", " DEFAULT 1"));
415
+ if (!cols.has("executed_at")) await tryExec(migrationAddColumnSql(db, "executed_at", tsType));
416
+ if (!cols.has("batch")) await tryExec(migrationAddColumnSql(db, "batch", "INTEGER", " DEFAULT 1"));
395
417
 
396
418
  // Copy legacy values across so applied migrations remain applied.
397
- await tryExec(`UPDATE "${MIGRATION_TABLE}" SET migration_name = name WHERE migration_name IS NULL`);
398
- await tryExec(`UPDATE "${MIGRATION_TABLE}" SET passed = 1 WHERE passed IS NULL`);
419
+ await tryExec(`UPDATE ${mt(db)} SET migration_name = name WHERE migration_name IS NULL`);
420
+ await tryExec(`UPDATE ${mt(db)} SET passed = 1 WHERE passed IS NULL`);
399
421
  if (cols.has("applied_at")) {
400
- await tryExec(`UPDATE "${MIGRATION_TABLE}" SET executed_at = applied_at WHERE executed_at IS NULL`);
422
+ await tryExec(`UPDATE ${mt(db)} SET executed_at = applied_at WHERE executed_at IS NULL`);
401
423
  }
402
424
  return;
403
425
  }
@@ -405,7 +427,7 @@ async function upgradeMigrationTable(db: DatabaseAdapter): Promise<void> {
405
427
  // Any other legacy shape: just ensure the batch column exists (prior behaviour).
406
428
  if (!cols.has("batch")) {
407
429
  try {
408
- await adapterExecute(db, migrationAddColumnSql(fb, "batch", "INTEGER", " NOT NULL DEFAULT 1"));
430
+ await adapterExecute(db, migrationAddColumnSql(db, "batch", "INTEGER", " NOT NULL DEFAULT 1"));
409
431
  } catch {
410
432
  // ignore — column may already exist
411
433
  }
@@ -426,7 +448,7 @@ export async function ensureMigrationTable(): Promise<void> {
426
448
  export async function getNextBatch(): Promise<number> {
427
449
  const adapter = getAdapter();
428
450
  const rows = await adapterQuery<{ max_batch: number | null }>(adapter,
429
- `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
451
+ `SELECT MAX(batch) as max_batch FROM ${mt(adapter)} WHERE passed = 1`,
430
452
  );
431
453
  return (rows[0]?.max_batch ?? 0) + 1;
432
454
  }
@@ -437,7 +459,7 @@ export async function getNextBatch(): Promise<number> {
437
459
  export async function isMigrationApplied(name: string): Promise<boolean> {
438
460
  const adapter = getAdapter();
439
461
  const rows = await adapterQuery(adapter,
440
- `SELECT id FROM "${MIGRATION_TABLE}" WHERE migration_name = ? AND passed = 1`,
462
+ `SELECT id FROM ${mt(adapter)} WHERE migration_name = ? AND passed = 1`,
441
463
  [name],
442
464
  );
443
465
  return rows.length > 0;
@@ -474,7 +496,7 @@ async function recordApplied(
474
496
  // Delete-before-insert: supersede any leftover row for this migration_name so
475
497
  // the INSERT below never collides on the UNIQUE migration_name.
476
498
  await adapterExecute(db,
477
- `DELETE FROM "${MIGRATION_TABLE}" WHERE migration_name = ?`,
499
+ `DELETE FROM ${mt(db)} WHERE migration_name = ?`,
478
500
  [name],
479
501
  );
480
502
  // Build the column list from the columns that ACTUALLY exist on the table, so
@@ -504,7 +526,7 @@ async function recordApplied(
504
526
 
505
527
  const placeholders = insertCols.map(() => "?").join(", ");
506
528
  await adapterExecute(db,
507
- `INSERT INTO "${MIGRATION_TABLE}" (${insertCols.join(", ")}) VALUES (${placeholders})`,
529
+ `INSERT INTO ${mt(db)} (${insertCols.join(", ")}) VALUES (${placeholders})`,
508
530
  values,
509
531
  );
510
532
  }
@@ -564,13 +586,13 @@ export async function applyMigration(
564
586
  export async function getLastBatchMigrations(): Promise<Array<{ id: number; migration_name: string; batch: number }>> {
565
587
  const adapter = getAdapter();
566
588
  const rows = await adapterQuery<{ max_batch: number | null }>(adapter,
567
- `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
589
+ `SELECT MAX(batch) as max_batch FROM ${mt(adapter)} WHERE passed = 1`,
568
590
  );
569
591
  const lastBatch = rows[0]?.max_batch;
570
592
  if (lastBatch === null || lastBatch === undefined) return [];
571
593
 
572
594
  return adapterQuery<{ id: number; migration_name: string; batch: number }>(adapter,
573
- `SELECT id, migration_name, batch FROM "${MIGRATION_TABLE}" WHERE batch = ? AND passed = 1 ORDER BY id DESC`,
595
+ `SELECT id, migration_name, batch FROM ${mt(adapter)} WHERE batch = ? AND passed = 1 ORDER BY id DESC`,
574
596
  [lastBatch],
575
597
  );
576
598
  }
@@ -581,7 +603,7 @@ export async function getLastBatchMigrations(): Promise<Array<{ id: number; migr
581
603
  export async function removeMigrationRecord(name: string): Promise<void> {
582
604
  const adapter = getAdapter();
583
605
  await adapterExecute(adapter,
584
- `DELETE FROM "${MIGRATION_TABLE}" WHERE migration_name = ?`,
606
+ `DELETE FROM ${mt(adapter)} WHERE migration_name = ?`,
585
607
  [name],
586
608
  );
587
609
  }
@@ -589,17 +611,26 @@ export async function removeMigrationRecord(name: string): Promise<void> {
589
611
  /**
590
612
  * Rollback the last batch of migrations using .down.sql files.
591
613
  *
592
- * For each migration in the last batch (in reverse order):
593
- * 1. Looks for a corresponding .down.sql file on disk
594
- * 2. If found, reads and executes the SQL statements
595
- * 3. If not found, logs a warning
596
- * 4. Deletes the tracking record either way
614
+ * FAIL-SAFE (MIG-DEC-02, reuses the Python reference model): for each
615
+ * migration in the last batch (in reverse order), the down artifact must
616
+ * actually run before the tracking record is removed. A MISSING .down.sql
617
+ * (or, on the legacy Map API, no registered down function) or a FAILING down
618
+ * statement now THROWS instead of logging a warning/error and still deleting
619
+ * the record — the old behaviour was the exact MIG-ROLLBACK-DROPS-LEDGER bug:
620
+ * the schema stayed applied but the ledger row vanished, silently untracked.
621
+ * The DELETE runs inside the SAME transaction as the down statements, so a
622
+ * partially-executed down (some statements ran, a later one failed) rolls
623
+ * back too — no half-reversed schema left behind either.
597
624
  *
598
625
  * @param migrationsDir - Directory containing migration files (default: "migrations")
599
626
  * @param delimiter - SQL statement delimiter (default: ";")
600
627
  * @returns Array of the down-migration files that were run, e.g.
601
628
  * "000001_create_users.down.sql". (The legacy down-FUNCTION Map API returns the
602
629
  * bare migration name instead, since no .down.sql file is involved there.)
630
+ * @throws When a migration in the batch has no down artifact, or its down
631
+ * statement(s) fail — the batch stops at that migration; earlier
632
+ * migrations in the SAME call that already rolled back stay rolled back
633
+ * (each is its own transaction).
603
634
  *
604
635
  * NOTE on return form (intentional, cross-framework): migration return values reflect
605
636
  * WHAT each method acted on, so the forms differ by method and that is by design (not
@@ -620,9 +651,12 @@ export async function rollback(
620
651
  const rolledBack: string[] = [];
621
652
  for (const migration of migrations) {
622
653
  const down = downFunctions.get(migration.migration_name);
623
- if (down) {
624
- await down();
654
+ if (!down) {
655
+ throw new Error(
656
+ `Cannot rollback ${migration.migration_name}: no down function registered`,
657
+ );
625
658
  }
659
+ await down();
626
660
  await removeMigrationRecord(migration.migration_name);
627
661
  // Legacy down-FUNCTION API: no .down.sql file is involved here, so return the
628
662
  // bare migration name (the file-based path below returns "name.down.sql").
@@ -642,32 +676,41 @@ export async function rollback(
642
676
  const downFile = `${migration.migration_name}.down.sql`;
643
677
  const downPath = join(dir, downFile);
644
678
 
645
- if (existsSync(downPath)) {
646
- const sqlContent = readFileSync(downPath, "utf-8").trim();
647
- if (sqlContent) {
648
- const statements = splitStatements(sqlContent, delim);
679
+ if (!existsSync(downPath)) {
680
+ throw new Error(
681
+ `Cannot rollback ${migration.migration_name}: no .down.sql file found`,
682
+ );
683
+ }
684
+
685
+ const sqlContent = readFileSync(downPath, "utf-8").trim();
686
+ if (sqlContent) {
687
+ const statements = splitStatements(sqlContent, delim);
688
+ try {
689
+ await adapterStartTransaction(db);
690
+ for (const stmt of statements) {
691
+ await adapterExecute(db, stmt);
692
+ }
693
+ // Remove the tracking record INSIDE the same transaction as the down
694
+ // statements, so a failure below rolls back the DDL too, not just the
695
+ // record removal.
696
+ await removeMigrationRecord(migration.migration_name);
697
+ await adapterCommit(db);
698
+ } catch (err) {
649
699
  try {
650
- await adapterStartTransaction(db);
651
- for (const stmt of statements) {
652
- await adapterExecute(db, stmt);
653
- }
654
- await adapterCommit(db);
655
- } catch (err) {
656
- try {
657
- await adapterRollback(db);
658
- } catch {
659
- // rollback may fail if auto-rolled-back
660
- }
661
- const msg = err instanceof Error ? err.message : String(err);
662
- console.error(` Rollback failed for ${migration.migration_name}: ${msg}`);
663
- // Still remove the record so the migration can be re-applied
700
+ await adapterRollback(db);
701
+ } catch {
702
+ // rollback may fail if auto-rolled-back
664
703
  }
704
+ const msg = err instanceof Error ? err.message : String(err);
705
+ throw new Error(`Rollback failed: ${migration.migration_name} — ${msg}`);
665
706
  }
666
707
  } else {
667
- console.warn(` Warning: No .down.sql file found for ${migration.migration_name} — skipping SQL execution`);
708
+ // An EXISTING but empty/comment-only .down.sql (0 statements) is a
709
+ // deliberate no-op success — matches the Python reference
710
+ // (createMigration scaffolds an empty .down.sql by default).
711
+ await removeMigrationRecord(migration.migration_name);
668
712
  }
669
713
 
670
- await removeMigrationRecord(migration.migration_name);
671
714
  // Return the down-migration file that was run (e.g. "name.down.sql"), matching
672
715
  // the Python master's rollback return form.
673
716
  rolledBack.push(`${migration.migration_name}.down.sql`);
@@ -682,7 +725,7 @@ export async function rollback(
682
725
  export async function getAppliedMigrations(): Promise<Array<{ id: number; migration_name: string; description: string; batch: number; executed_at: string; passed: number }>> {
683
726
  const adapter = getAdapter();
684
727
  return adapterQuery<{ id: number; migration_name: string; description: string; batch: number; executed_at: string; passed: number }>(adapter,
685
- `SELECT * FROM "${MIGRATION_TABLE}" WHERE passed = 1 ORDER BY id ASC`,
728
+ `SELECT * FROM ${mt(adapter)} WHERE passed = 1 ORDER BY id ASC`,
686
729
  );
687
730
  }
688
731
 
@@ -1007,7 +1050,7 @@ export async function migrate(
1007
1050
  let currentBatch = 1;
1008
1051
  try {
1009
1052
  const batchRows = await adapterQuery<{ max_batch: number | null }>(db,
1010
- `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
1053
+ `SELECT MAX(batch) as max_batch FROM ${mt(db)} WHERE passed = 1`,
1011
1054
  );
1012
1055
  currentBatch = (batchRows[0]?.max_batch ?? 0) + 1;
1013
1056
  } catch {
@@ -1023,7 +1066,7 @@ export async function migrate(
1023
1066
  let alreadyApplied = false;
1024
1067
  try {
1025
1068
  const existing = await adapterQuery<{ id: number }>(db,
1026
- `SELECT id FROM "${MIGRATION_TABLE}" WHERE migration_name = ? AND passed = 1`,
1069
+ `SELECT id FROM ${mt(db)} WHERE migration_name = ? AND passed = 1`,
1027
1070
  [migrationId],
1028
1071
  );
1029
1072
  alreadyApplied = existing.length > 0;
@@ -1136,7 +1179,7 @@ export async function status(
1136
1179
  const appliedNames = new Set<string>();
1137
1180
  try {
1138
1181
  const rows = await adapterQuery<{ migration_name: string }>(db,
1139
- `SELECT migration_name FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
1182
+ `SELECT migration_name FROM ${mt(db)} WHERE passed = 1`,
1140
1183
  );
1141
1184
  for (const row of rows) {
1142
1185
  if (row.migration_name) appliedNames.add(row.migration_name);
@@ -1,4 +1,5 @@
1
1
  import type { QueryOptions } from "./types.js";
2
+ import { DEFAULT_ROW_CAP } from "./database.js";
2
3
 
3
4
  export interface ParsedQuery {
4
5
  where: string;
@@ -12,7 +13,7 @@ export function buildQuery(
12
13
  tableName: string,
13
14
  options: QueryOptions,
14
15
  extraConditions?: string[],
15
- ): { sql: string; countSql: string; params: unknown[] } {
16
+ ): { sql: string; countSql: string; params: unknown[]; limit: number; offset: number; page: number } {
16
17
  const conditions: string[] = [];
17
18
  const params: unknown[] = [];
18
19
 
@@ -57,9 +58,17 @@ export function buildQuery(
57
58
  orderClause = `ORDER BY ${parts.join(", ")}`;
58
59
  }
59
60
 
60
- // Pagination
61
- const limit = options.limit ?? 100;
62
- const page = options.page ?? 1;
61
+ // Pagination — PAGE-DEC-01: clamp page >= 1 BEFORE deriving offset, so
62
+ // offset=(page-1)*limit can never go negative (a page=0/negative request used
63
+ // to hand the driver a negative OFFSET - a hard error on PostgreSQL and a
64
+ // silent-wrong result on SQLite), and cap the per-page size at DEFAULT_ROW_CAP
65
+ // (100 - the same row cap Database.fetch()/BaseModel.all() already share) so a
66
+ // client cannot request the whole table in one query. Returning the clamped
67
+ // limit/offset/page (not just using them locally) lets the caller build the
68
+ // REST envelope from the values the SQL actually used, instead of recomputing
69
+ // the same arithmetic a second time from the raw, unclamped query params.
70
+ const limit = Math.min(options.limit ?? DEFAULT_ROW_CAP, DEFAULT_ROW_CAP);
71
+ const page = Math.max(options.page ?? 1, 1);
63
72
  const offset = (page - 1) * limit;
64
73
 
65
74
  const sql = `SELECT * FROM "${tableName}" ${whereClause} ${orderClause} LIMIT ? OFFSET ?`;
@@ -69,6 +78,9 @@ export function buildQuery(
69
78
  sql,
70
79
  countSql,
71
80
  params: [...params, limit, offset],
81
+ limit,
82
+ offset,
83
+ page,
72
84
  };
73
85
  }
74
86
 
@@ -14,7 +14,7 @@
14
14
  // real parent PKs, and warns on clear type mismatches.
15
15
 
16
16
  import { FakeData } from "./fakeData.js";
17
- import { adapterExecute, adapterFetch, adapterColumns } from "./database.js";
17
+ import { adapterExecute, adapterFetch, adapterColumns, adapterInsert } from "./database.js";
18
18
  import { Log } from "../../core/src/index.js";
19
19
  import type { DatabaseAdapter, FieldDefinition, FieldType } from "./types.js";
20
20
 
@@ -37,7 +37,18 @@ export interface SeedOptions {
37
37
  overrides?: Record<string, unknown>;
38
38
  /** Delete every existing row in the target before seeding (P2). */
39
39
  clear?: boolean;
40
- /** PRNG seed for reproducible FakeData output (P3). */
40
+ /**
41
+ * PRNG seed for reproducible FakeData output (P3). Honoured by seedOrm and
42
+ * seedModels, which build and seed their own FakeData internally.
43
+ *
44
+ * NOT honoured by seedTable (SEED-TABLE-SEED-INERT, SEED-DEC-01, ratified
45
+ * 2026-08-11 — same principle as the no-op ForeignKeyField on_delete):
46
+ * seedTable has no generators of its own to seed — fieldMap callables are
47
+ * opaque — so this used to be a silent no-op there. Passing it to seedTable
48
+ * now THROWS instead. Build your own `new FakeData(seed)` and close over it
49
+ * in fieldMap: `const fake = new FakeData(42); seedTable(db, table, count,
50
+ * { name: () => fake.name() })`.
51
+ */
41
52
  seed?: number;
42
53
  /** Re-raise on the first failed row instead of skipping it (P1). */
43
54
  strict?: boolean;
@@ -51,14 +62,13 @@ export interface SeedOptions {
51
62
  function normaliseOptions(
52
63
  overrides?: Record<string, unknown>,
53
64
  opts?: SeedOptions,
54
- ): Required<Pick<SeedOptions, "clear" | "strict">> & { overrides?: Record<string, unknown>; seed?: number } {
65
+ ): Required<Pick<SeedOptions, "clear" | "strict">> & { overrides?: Record<string, unknown> } {
55
66
  const merged: SeedOptions = { ...(opts ?? {}) };
56
67
  // The new `opts.overrides` takes precedence if both are supplied.
57
68
  const effectiveOverrides = merged.overrides ?? overrides;
58
69
  return {
59
70
  overrides: effectiveOverrides,
60
71
  clear: merged.clear ?? false,
61
- seed: merged.seed,
62
72
  strict: merged.strict ?? false,
63
73
  };
64
74
  }
@@ -154,8 +164,10 @@ export async function autoFieldMap(
154
164
  * (or a static value). If not provided, no rows are inserted.
155
165
  * @param overrides - (legacy positional) Static values applied to every row.
156
166
  * Prefer `opts.overrides`.
157
- * @param opts - Seed options: `{ overrides, clear, seed, strict }`.
167
+ * @param opts - Seed options: `{ overrides, clear, strict }`. `opts.seed` is
168
+ * NOT honoured here (see {@link SeedOptions.seed}) and throws if supplied.
158
169
  * @returns A SeedSummary `{ seeded, failed, errors }`.
170
+ * @throws {Error} If `opts.seed` is defined (SEED-TABLE-SEED-INERT removal).
159
171
  *
160
172
  * @example
161
173
  * const fake = new FakeData();
@@ -172,6 +184,15 @@ export async function seedTable(
172
184
  overrides?: Record<string, unknown>,
173
185
  opts?: SeedOptions,
174
186
  ): Promise<SeedSummary> {
187
+ if (opts?.seed !== undefined) {
188
+ throw new Error(
189
+ "seedTable() no longer accepts opts.seed: it has no generators of its own to seed " +
190
+ "(fieldMap callables are opaque). Build a seeded FakeData yourself and close over it " +
191
+ "in fieldMap, e.g. const fake = new FakeData(42); seedTable(db, table, count, " +
192
+ "{ name: () => fake.name() }).",
193
+ );
194
+ }
195
+
175
196
  const { overrides: effectiveOverrides, clear, strict } = normaliseOptions(overrides, opts);
176
197
 
177
198
  if (!fieldMap || Object.keys(fieldMap).length === 0) {
@@ -202,18 +223,19 @@ export async function seedTable(
202
223
  }
203
224
  }
204
225
 
205
- // Build INSERT SQL
206
- const columns = Object.keys(row);
207
- const colList = columns.map((c) => `"${c}"`).join(", ");
208
- const placeholders = columns.map(() => "?").join(", ");
209
- const values = columns.map((c) => row[c]);
210
-
211
- // adapterExecute() RAISES on a constraint/SQL error since v3.13.x — the
226
+ // Route through the adapter's OWN native insert path (feature 3's
227
+ // shared buildInsert()/Dialect) instead of hand-built SQL with a fixed
228
+ // quote style. A hardcoded double-quote works on PostgreSQL/MSSQL/
229
+ // SQLite but breaks Firebird: an unquoted CREATE TABLE folds the name
230
+ // to UPPERCASE, so a quoted lower-case INSERT target is a DIFFERENT,
231
+ // unfindable identifier ("Table unknown" -204) — the SAME class of
232
+ // engine-portability bug as PHP's old backtick-quoted seed_table.
233
+ // adapterInsert() RAISES on a constraint/SQL error since v3.13.x — the
212
234
  // try/except is what turns that into a counted, logged, skipped failure.
213
- await adapterExecute(
235
+ await adapterInsert(
214
236
  db,
215
- `INSERT INTO "${tableName}" (${colList}) VALUES (${placeholders})`,
216
- values,
237
+ tableName,
238
+ row,
217
239
  );
218
240
  seeded++;
219
241
  } catch (e) {
@@ -410,16 +432,12 @@ export async function seedOrm(
410
432
  }
411
433
  validateTypes(fields, attrs, modelName);
412
434
 
413
- const columns = Object.keys(attrs);
414
- const colList = columns.map((c) => `"${c}"`).join(", ");
415
- const placeholders = columns.map(() => "?").join(", ");
416
- const values = columns.map((c) => attrs[c]);
417
-
418
- await adapterExecute(
419
- db,
420
- `INSERT INTO "${ormClass.tableName}" (${colList}) VALUES (${placeholders})`,
421
- values,
422
- );
435
+ // Route through the adapter's OWN native insert path (feature 3's
436
+ // shared buildInsert()/Dialect) see the identical note in seedTable()
437
+ // above. A hand-built, unconditionally double-quoted INSERT breaks
438
+ // Firebird (asymmetric case-folding: an unquoted CREATE TABLE stores
439
+ // UPPERCASE, so a quoted lower-case INSERT target is unfindable).
440
+ await adapterInsert(db, ormClass.tableName, attrs);
423
441
  seeded++;
424
442
  } catch (e) {
425
443
  const message = (e as Error).message ?? String(e);
@@ -67,37 +67,119 @@ export class SQLTranslator {
67
67
  return sql;
68
68
  }
69
69
 
70
+ // ── Literal-safe rewriting ──────────────────────────────────────
71
+ //
72
+ // A dialect rewrite (|| -> CONCAT, TRUE -> 1, ILIKE -> LOWER LIKE) must NEVER
73
+ // touch text inside a string literal, a quoted identifier or a comment: a
74
+ // column value of 'a||b', a label 'TRUE', or a LIKE pattern that mentions
75
+ // ILIKE is DATA, not SQL. Each transform masks every literal/identifier/comment
76
+ // to an opaque token, rewrites the masked SQL, then restores the tokens, so the
77
+ // rewrite only ever sees real SQL structure.
78
+
79
+ /** Replace string literals, quoted identifiers and comments with opaque
80
+ * `\x00N\x00` tokens (doubled-quote escapes handled). */
81
+ private static maskLiterals(sql: string): { masked: string; literals: string[] } {
82
+ const literals: string[] = [];
83
+ let out = "";
84
+ let i = 0;
85
+ const n = sql.length;
86
+ while (i < n) {
87
+ const c = sql[i];
88
+ const next = sql[i + 1];
89
+ if (c === "'" || c === '"' || c === "`") {
90
+ const start = i;
91
+ i++;
92
+ while (i < n) {
93
+ if (sql[i] === c) {
94
+ if (sql[i + 1] === c) { i += 2; continue; }
95
+ i++;
96
+ break;
97
+ }
98
+ i++;
99
+ }
100
+ out += `\x00${literals.length}\x00`;
101
+ literals.push(sql.slice(start, i));
102
+ continue;
103
+ }
104
+ if (c === "-" && next === "-") {
105
+ const start = i;
106
+ while (i < n && sql[i] !== "\n") i++;
107
+ out += `\x00${literals.length}\x00`;
108
+ literals.push(sql.slice(start, i));
109
+ continue;
110
+ }
111
+ if (c === "/" && next === "*") {
112
+ const start = i;
113
+ i += 2;
114
+ while (i < n && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
115
+ i = Math.min(i + 2, n);
116
+ out += `\x00${literals.length}\x00`;
117
+ literals.push(sql.slice(start, i));
118
+ continue;
119
+ }
120
+ out += c;
121
+ i++;
122
+ }
123
+ return { masked: out, literals };
124
+ }
125
+
126
+ /** Inverse of maskLiterals. */
127
+ private static restoreLiterals(masked: string, literals: string[]): string {
128
+ return masked.replace(/\x00(\d+)\x00/g, (_m, idx) => literals[Number(idx)]);
129
+ }
130
+
131
+ // A concat/ilike operand: a masked literal-or-identifier token, a simple
132
+ // function call, a (qualified) identifier, a placeholder, or a number. The
133
+ // function-call args exclude `|` so a nested `||` never splits the chain.
134
+ private static readonly PRIMARY =
135
+ "(?:\\x00\\d+\\x00|[A-Za-z_][\\w$]*\\s*\\([^()|]*\\)|[A-Za-z_][\\w$]*(?:\\.[A-Za-z_][\\w$]*)*|:[A-Za-z_]\\w*|\\$\\d+|\\?|%s|\\d+(?:\\.\\d+)?)";
136
+
70
137
  /**
71
- * Convert || concatenation to CONCAT() for MySQL/MSSQL.
138
+ * Convert `||` string concatenation to `CONCAT(...)` for MySQL/MSSQL.
72
139
  *
73
- * 'a' || 'b' || 'c' → CONCAT('a', 'b', 'c')
140
+ * Rewrites ONLY `||` operators joining expression operands OUTSIDE any string
141
+ * literal or comment, and only the operand chain — never the whole statement:
142
+ * SELECT a || b FROM t -> SELECT CONCAT(a, b) FROM t
143
+ * WHERE data = 'a||b' -> WHERE data = 'a||b' (literal untouched)
74
144
  */
75
145
  static concatPipesToFunc(sql: string): string {
76
146
  if (!sql.includes("||")) return sql;
77
- const parts = sql.split("||");
78
- if (parts.length > 1) {
79
- return "CONCAT(" + parts.map((p) => p.trim()).join(", ") + ")";
80
- }
81
- return sql;
147
+ const { masked, literals } = SQLTranslator.maskLiterals(sql);
148
+ if (!masked.includes("||")) return sql; // every || was inside a literal/comment
149
+ const chain = new RegExp(
150
+ `${SQLTranslator.PRIMARY}(?:\\s*\\|\\|\\s*${SQLTranslator.PRIMARY})+`,
151
+ "g",
152
+ );
153
+ const rewritten = masked.replace(chain, (m) => "CONCAT(" + m.split(/\s*\|\|\s*/).join(", ") + ")");
154
+ return SQLTranslator.restoreLiterals(rewritten, literals);
82
155
  }
83
156
 
84
157
  /**
85
- * Convert TRUE/FALSE to 1/0 for engines without boolean type (Firebird).
158
+ * Convert bare TRUE/FALSE to 1/0 for engines without a boolean type. A
159
+ * TRUE/FALSE INSIDE a string literal is data and is left untouched
160
+ * (`WHERE label = 'TRUE'` is preserved).
86
161
  */
87
162
  static booleanToInt(sql: string): string {
88
- sql = sql.replace(/\bTRUE\b/gi, "1");
89
- sql = sql.replace(/\bFALSE\b/gi, "0");
90
- return sql;
163
+ if (!/\b(?:TRUE|FALSE)\b/i.test(sql)) return sql;
164
+ const { masked, literals } = SQLTranslator.maskLiterals(sql);
165
+ const rewritten = masked.replace(/\bTRUE\b/gi, "1").replace(/\bFALSE\b/gi, "0");
166
+ return SQLTranslator.restoreLiterals(rewritten, literals);
91
167
  }
92
168
 
93
169
  /**
94
- * Convert ILIKE to LOWER() LIKE LOWER() for engines without ILIKE.
170
+ * Convert `col ILIKE pattern` to `LOWER(col) LIKE LOWER(pattern)` for engines
171
+ * without ILIKE. The pattern operand is captured whole (a multi-word
172
+ * `'%two words%'` survives) and an ILIKE INSIDE a string literal is untouched.
95
173
  */
96
174
  static ilikeToLike(sql: string): string {
97
- return sql.replace(
98
- /(\S+)\s+ILIKE\s+(\S+)/gi,
99
- (_match, col: string, val: string) => `LOWER(${col.trim()}) LIKE LOWER(${val.trim()})`,
175
+ if (!/ilike/i.test(sql)) return sql;
176
+ const { masked, literals } = SQLTranslator.maskLiterals(sql);
177
+ const re = new RegExp(
178
+ `(${SQLTranslator.PRIMARY})\\s+ILIKE\\s+(${SQLTranslator.PRIMARY})`,
179
+ "gi",
100
180
  );
181
+ const rewritten = masked.replace(re, (_m, col: string, val: string) => `LOWER(${col}) LIKE LOWER(${val})`);
182
+ return SQLTranslator.restoreLiterals(rewritten, literals);
101
183
  }
102
184
 
103
185
  /**
@@ -108,10 +190,13 @@ export class SQLTranslator {
108
190
  case "mysql":
109
191
  return sql.replace(/AUTOINCREMENT/gi, "AUTO_INCREMENT");
110
192
  case "postgresql":
111
- return sql.replace(
112
- /INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi,
113
- "SERIAL PRIMARY KEY",
114
- );
193
+ // BIGINT PRIMARY KEY AUTOINCREMENT -> BIGSERIAL (a real 64-bit sequence);
194
+ // INTEGER PRIMARY KEY AUTOINCREMENT -> SERIAL. A plain BIGINT with the
195
+ // keyword merely stripped has no sequence and cannot auto-increment.
196
+ return sql
197
+ .replace(/\bBIGINT\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/gi, "BIGSERIAL PRIMARY KEY")
198
+ .replace(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/gi, "SERIAL PRIMARY KEY")
199
+ .replace(/\s*\bAUTOINCREMENT\b/gi, "");
115
200
  case "mssql":
116
201
  return sql.replace(/AUTOINCREMENT/gi, "IDENTITY(1,1)");
117
202
  case "firebird":