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.
- package/CLAUDE.md +60 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20620 -18995
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20459 -18815
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +28 -12
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +31 -13
- package/packages/frond/src/engine.ts +39 -7
- package/packages/orm/dist/index.js +10879 -9258
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +10 -0
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- 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(
|
|
295
|
-
return `ALTER TABLE
|
|
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
|
|
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}
|
|
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(
|
|
391
|
-
if (!cols.has("description")) await tryExec(migrationAddColumnSql(
|
|
392
|
-
if (!cols.has("passed")) await tryExec(migrationAddColumnSql(
|
|
393
|
-
if (!cols.has("executed_at")) await tryExec(migrationAddColumnSql(
|
|
394
|
-
if (!cols.has("batch")) await tryExec(migrationAddColumnSql(
|
|
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
|
|
398
|
-
await tryExec(`UPDATE
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
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
|
|
235
|
+
await adapterInsert(
|
|
214
236
|
db,
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
await
|
|
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
|
|
138
|
+
* Convert `||` string concatenation to `CONCAT(...)` for MySQL/MSSQL.
|
|
72
139
|
*
|
|
73
|
-
*
|
|
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
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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":
|