tina4-nodejs 3.13.53 → 3.13.55

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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.53)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.55)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.53 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.55 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.53",
3
+ "version": "3.13.55",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -40,6 +40,7 @@ export {
40
40
  status,
41
41
  Migration,
42
42
  splitStatements,
43
+ parseSetTerm,
43
44
  normalizeQuotes,
44
45
  sortMigrationFiles,
45
46
  shouldSkipCreateTable,
@@ -272,89 +272,179 @@ function buildAddColumnSql(
272
272
  const MIGRATION_TABLE = "tina4_migration";
273
273
 
274
274
  /**
275
- * Ensure the migration tracking table exists with batch support.
275
+ * Derive a human-readable description from a migration name, matching the
276
+ * Python master: strip a leading numeric/timestamp prefix and turn `_` into
277
+ * spaces. `20250101120000_create_users` -> `create users`.
276
278
  */
277
- export async function ensureMigrationTable(): Promise<void> {
278
- const adapter = getAdapter();
279
- if (!(await adapterTableExists(adapter, MIGRATION_TABLE))) {
280
- if (isFirebirdAdapter(adapter)) {
281
- // Firebird: no AUTOINCREMENT, no TEXT type, use generator for IDs
279
+ function deriveDescription(name: string): string {
280
+ return name.replace(/^\d+_/, "").replace(/_/g, " ");
281
+ }
282
+
283
+ /**
284
+ * Build an `ALTER TABLE ... ADD` statement for the tracking table. Firebird
285
+ * uses `ADD <col>` (no COLUMN keyword); every other engine uses `ADD COLUMN`.
286
+ */
287
+ function migrationAddColumnSql(fb: boolean, col: string, type: string, extra = ""): string {
288
+ return `ALTER TABLE "${MIGRATION_TABLE}" ADD ${fb ? "" : "COLUMN "}${col} ${type}${extra}`;
289
+ }
290
+
291
+ /**
292
+ * The canonical `tina4_migration` bookkeeping shape, shared across all four
293
+ * Tina4 frameworks (3.13.55 parity):
294
+ *
295
+ * id <engine auto-increment PK — migrationIdColumn() / Firebird generator>
296
+ * migration_name VARCHAR(500) NOT NULL UNIQUE
297
+ * description VARCHAR(500)
298
+ * batch INTEGER NOT NULL DEFAULT 1
299
+ * executed_at VARCHAR(50) -- a written ISO-8601 string, NOT a CURRENT_TIMESTAMP default
300
+ * passed INTEGER NOT NULL DEFAULT 1
301
+ *
302
+ * A migration is "applied" iff a row exists with passed = 1. Operates on the
303
+ * passed adapter so both ensureMigrationTable() (global adapter) and migrate()
304
+ * (its own adapter) share ONE implementation — mirrors Python's single
305
+ * `_ensure_tracking_table`.
306
+ */
307
+ async function ensureMigrationTableOn(db: DatabaseAdapter): Promise<void> {
308
+ if (!(await adapterTableExists(db, MIGRATION_TABLE))) {
309
+ if (isFirebirdAdapter(db)) {
310
+ // Firebird: no AUTOINCREMENT, no TEXT type, use a generator for IDs.
282
311
  try {
283
- await adapterExecute(adapter, "CREATE GENERATOR GEN_TINA4_MIGRATION_ID");
284
- try { await adapterExecute(adapter, "COMMIT"); } catch { /* ignore */ }
312
+ await adapterExecute(db, "CREATE GENERATOR GEN_TINA4_MIGRATION_ID");
313
+ try { await adapterExecute(db, "COMMIT"); } catch { /* ignore */ }
285
314
  } catch {
286
315
  // Generator may already exist
287
316
  }
288
- await adapterExecute(adapter, `CREATE TABLE "${MIGRATION_TABLE}" (
317
+ await adapterExecute(db, `CREATE TABLE "${MIGRATION_TABLE}" (
289
318
  id INTEGER NOT NULL PRIMARY KEY,
290
- name VARCHAR(500) NOT NULL,
319
+ migration_name VARCHAR(500) NOT NULL UNIQUE,
320
+ description VARCHAR(500),
291
321
  batch INTEGER NOT NULL DEFAULT 1,
292
- applied_at VARCHAR(50) NOT NULL
322
+ executed_at VARCHAR(50) NOT NULL,
323
+ passed INTEGER NOT NULL DEFAULT 1
293
324
  )`);
294
325
  } else {
295
- // Engine-aware bookkeeping DDL (non-Firebird) same per-engine id column
296
- // and column types as the inline `migrate()` bootstrap. Raw AUTOINCREMENT
297
- // is SQLite-only and a syntax error elsewhere; SERIAL/AUTO_INCREMENT/
298
- // IDENTITY(1,1) are produced via migrationIdColumn(). VARCHAR name (UNIQUE-
299
- // safe on MySQL) and DATETIME applied_at on MSSQL (TIMESTAMP is rowversion
300
- // there). SQLite gives VARCHAR TEXT affinity, so SQLite stays unchanged.
301
- // applied_at keeps a CURRENT_TIMESTAMP default so recordMigration() (which
302
- // inserts only name+batch) still works — same as the prior createTable.
303
- const idCol = migrationIdColumn(adapter);
304
- const engine = engineOf(adapter);
326
+ // Engine-aware bookkeeping DDL (non-Firebird). Each engine spells an
327
+ // auto-increment integer PK differently SQLite AUTOINCREMENT, Postgres
328
+ // SERIAL, MySQL AUTO_INCREMENT, MSSQL IDENTITY(1,1) via
329
+ // migrationIdColumn(). migration_name is VARCHAR (a TEXT column cannot
330
+ // carry UNIQUE on MySQL); SQLite gives VARCHAR TEXT affinity so it stays
331
+ // behaviour-identical there. executed_at is a plain VARCHAR(50) written
332
+ // explicitly (new Date().toISOString()) — NOT a CURRENT_TIMESTAMP default.
333
+ const idCol = migrationIdColumn(db);
334
+ const engine = engineOf(db);
305
335
  const ifNotExists = engine === "mssql" ? "" : "IF NOT EXISTS ";
306
- const appliedType = engine === "mssql" ? "DATETIME" : "TEXT";
307
- await adapterExecute(adapter, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
336
+ await adapterExecute(db, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
308
337
  ${idCol},
309
- name VARCHAR(500) NOT NULL,
338
+ migration_name VARCHAR(500) NOT NULL UNIQUE,
339
+ description VARCHAR(500),
310
340
  batch INTEGER NOT NULL DEFAULT 1,
311
- applied_at ${appliedType} NOT NULL DEFAULT CURRENT_TIMESTAMP
341
+ executed_at VARCHAR(50) NOT NULL,
342
+ passed INTEGER NOT NULL DEFAULT 1
312
343
  )`);
313
344
  }
314
- } else {
315
- // Ensure batch column exists on older tables that only had passed/description
345
+ return;
346
+ }
347
+ await upgradeMigrationTable(db);
348
+ }
349
+
350
+ /**
351
+ * Non-destructive in-place upgrade of an existing tracking table.
352
+ *
353
+ * A table created by an OLDER v3 (<= 3.13.54) has `name` + `applied_at` and no
354
+ * `migration_name`/`description`/`passed`. Detect that shape, ADD the canonical
355
+ * columns, and copy the values across (`migration_name = name`, `passed = 1`,
356
+ * `executed_at = applied_at`) so already-applied migrations are still seen as
357
+ * applied and are NOT re-run. The old `name`/`applied_at` columns are left in
358
+ * place (harmless, ignored from now on). Any other legacy shape only has its
359
+ * missing `batch` column ensured (preserves the prior behaviour).
360
+ */
361
+ async function upgradeMigrationTable(db: DatabaseAdapter): Promise<void> {
362
+ let cols: Set<string>;
363
+ try {
364
+ const columns = (db as any).getTableColumns
365
+ ? (db as SQLiteAdapter).getTableColumns(MIGRATION_TABLE)
366
+ : await adapterColumns(db, MIGRATION_TABLE);
367
+ cols = new Set(columns.map((c) => c.name.toLowerCase()));
368
+ } catch {
369
+ return; // cannot introspect — leave the table untouched
370
+ }
371
+
372
+ const fb = isFirebirdAdapter(db);
373
+
374
+ if (cols.has("name") && !cols.has("migration_name")) {
375
+ // Old-v3 -> canonical. Firebird has no TEXT type, so VARCHAR there.
376
+ const nameType = fb ? "VARCHAR(500)" : "TEXT";
377
+ const tsType = fb ? "VARCHAR(50)" : "TEXT";
378
+
379
+ const tryExec = async (sql: string): Promise<void> => {
380
+ try { await adapterExecute(db, sql); } catch { /* column may already exist */ }
381
+ };
382
+
383
+ await tryExec(migrationAddColumnSql(fb, "migration_name", nameType));
384
+ if (!cols.has("description")) await tryExec(migrationAddColumnSql(fb, "description", nameType));
385
+ if (!cols.has("passed")) await tryExec(migrationAddColumnSql(fb, "passed", "INTEGER", " DEFAULT 1"));
386
+ if (!cols.has("executed_at")) await tryExec(migrationAddColumnSql(fb, "executed_at", tsType));
387
+ if (!cols.has("batch")) await tryExec(migrationAddColumnSql(fb, "batch", "INTEGER", " DEFAULT 1"));
388
+
389
+ // Copy legacy values across so applied migrations remain applied.
390
+ await tryExec(`UPDATE "${MIGRATION_TABLE}" SET migration_name = name WHERE migration_name IS NULL`);
391
+ await tryExec(`UPDATE "${MIGRATION_TABLE}" SET passed = 1 WHERE passed IS NULL`);
392
+ if (cols.has("applied_at")) {
393
+ await tryExec(`UPDATE "${MIGRATION_TABLE}" SET executed_at = applied_at WHERE executed_at IS NULL`);
394
+ }
395
+ return;
396
+ }
397
+
398
+ // Any other legacy shape: just ensure the batch column exists (prior behaviour).
399
+ if (!cols.has("batch")) {
316
400
  try {
317
- const cols = (adapter as any).getTableColumns
318
- ? (adapter as SQLiteAdapter).getTableColumns(MIGRATION_TABLE)
319
- : await adapterColumns(adapter, MIGRATION_TABLE);
320
- const colNames = new Set(cols.map((c) => c.name.toLowerCase()));
321
- if (!colNames.has("batch")) {
322
- await adapterExecute(adapter, `ALTER TABLE "${MIGRATION_TABLE}" ADD COLUMN batch INTEGER NOT NULL DEFAULT 1`);
323
- }
401
+ await adapterExecute(db, migrationAddColumnSql(fb, "batch", "INTEGER", " NOT NULL DEFAULT 1"));
324
402
  } catch {
325
403
  // ignore — column may already exist
326
404
  }
327
405
  }
328
406
  }
329
407
 
408
+ /**
409
+ * Ensure the migration tracking table exists in the canonical shape (creating
410
+ * it or upgrading an older one in place) on the global adapter.
411
+ */
412
+ export async function ensureMigrationTable(): Promise<void> {
413
+ await ensureMigrationTableOn(getAdapter());
414
+ }
415
+
330
416
  /**
331
417
  * Get the current batch number (max batch + 1).
332
418
  */
333
419
  export async function getNextBatch(): Promise<number> {
334
420
  const adapter = getAdapter();
335
421
  const rows = await adapterQuery<{ max_batch: number | null }>(adapter,
336
- `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}"`,
422
+ `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
337
423
  );
338
424
  return (rows[0]?.max_batch ?? 0) + 1;
339
425
  }
340
426
 
341
427
  /**
342
- * Check if a migration has already been applied.
428
+ * Check if a migration has already been applied (a row with passed = 1).
343
429
  */
344
430
  export async function isMigrationApplied(name: string): Promise<boolean> {
345
431
  const adapter = getAdapter();
346
432
  const rows = await adapterQuery(adapter,
347
- `SELECT id FROM "${MIGRATION_TABLE}" WHERE name = ?`,
433
+ `SELECT id FROM "${MIGRATION_TABLE}" WHERE migration_name = ? AND passed = 1`,
348
434
  [name],
349
435
  );
350
436
  return rows.length > 0;
351
437
  }
352
438
 
353
439
  /**
354
- * Record a migration as applied.
440
+ * Record a migration as applied. Writes the canonical columns: migration_name,
441
+ * a derived description, batch, an explicit ISO-8601 executed_at timestamp, and
442
+ * passed (default 1). Preserves the Firebird generator-id branch.
355
443
  */
356
444
  export async function recordMigration(name: string, batch: number, passed: number = 1): Promise<void> {
357
445
  const adapter = getAdapter();
446
+ const now = new Date().toISOString();
447
+ const description = deriveDescription(name);
358
448
  if (isFirebirdAdapter(adapter)) {
359
449
  // Firebird: generate ID from sequence
360
450
  const rows = await adapterQuery<{ NEXT_ID: number }>(adapter,
@@ -362,13 +452,13 @@ export async function recordMigration(name: string, batch: number, passed: numbe
362
452
  );
363
453
  const nextId = rows[0]?.NEXT_ID ?? 1;
364
454
  await adapterExecute(adapter,
365
- `INSERT INTO "${MIGRATION_TABLE}" (id, name, batch) VALUES (?, ?, ?)`,
366
- [nextId, name, batch],
455
+ `INSERT INTO "${MIGRATION_TABLE}" (id, migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?, ?)`,
456
+ [nextId, name, description, batch, now, passed],
367
457
  );
368
458
  } else {
369
459
  await adapterExecute(adapter,
370
- `INSERT INTO "${MIGRATION_TABLE}" (name, batch) VALUES (?, ?)`,
371
- [name, batch],
460
+ `INSERT INTO "${MIGRATION_TABLE}" (migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?)`,
461
+ [name, description, batch, now, passed],
372
462
  );
373
463
  }
374
464
  }
@@ -391,16 +481,16 @@ export async function applyMigration(
391
481
  /**
392
482
  * Get all migrations from the last batch.
393
483
  */
394
- export async function getLastBatchMigrations(): Promise<Array<{ id: number; name: string; batch: number }>> {
484
+ export async function getLastBatchMigrations(): Promise<Array<{ id: number; migration_name: string; batch: number }>> {
395
485
  const adapter = getAdapter();
396
486
  const rows = await adapterQuery<{ max_batch: number | null }>(adapter,
397
- `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}"`,
487
+ `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
398
488
  );
399
489
  const lastBatch = rows[0]?.max_batch;
400
490
  if (lastBatch === null || lastBatch === undefined) return [];
401
491
 
402
- return adapterQuery<{ id: number; name: string; batch: number }>(adapter,
403
- `SELECT id, name, batch FROM "${MIGRATION_TABLE}" WHERE batch = ? ORDER BY id DESC`,
492
+ return adapterQuery<{ id: number; migration_name: string; batch: number }>(adapter,
493
+ `SELECT id, migration_name, batch FROM "${MIGRATION_TABLE}" WHERE batch = ? AND passed = 1 ORDER BY id DESC`,
404
494
  [lastBatch],
405
495
  );
406
496
  }
@@ -411,7 +501,7 @@ export async function getLastBatchMigrations(): Promise<Array<{ id: number; name
411
501
  export async function removeMigrationRecord(name: string): Promise<void> {
412
502
  const adapter = getAdapter();
413
503
  await adapterExecute(adapter,
414
- `DELETE FROM "${MIGRATION_TABLE}" WHERE name = ?`,
504
+ `DELETE FROM "${MIGRATION_TABLE}" WHERE migration_name = ?`,
415
505
  [name],
416
506
  );
417
507
  }
@@ -449,14 +539,14 @@ export async function rollback(
449
539
  const migrations = await getLastBatchMigrations();
450
540
  const rolledBack: string[] = [];
451
541
  for (const migration of migrations) {
452
- const down = downFunctions.get(migration.name);
542
+ const down = downFunctions.get(migration.migration_name);
453
543
  if (down) {
454
544
  await down();
455
545
  }
456
- await removeMigrationRecord(migration.name);
546
+ await removeMigrationRecord(migration.migration_name);
457
547
  // Legacy down-FUNCTION API: no .down.sql file is involved here, so return the
458
548
  // bare migration name (the file-based path below returns "name.down.sql").
459
- rolledBack.push(migration.name);
549
+ rolledBack.push(migration.migration_name);
460
550
  }
461
551
  return rolledBack;
462
552
  }
@@ -469,7 +559,7 @@ export async function rollback(
469
559
 
470
560
  for (const migration of migrations) {
471
561
  // Determine the .down.sql filename
472
- const downFile = `${migration.name}.down.sql`;
562
+ const downFile = `${migration.migration_name}.down.sql`;
473
563
  const downPath = join(dir, downFile);
474
564
 
475
565
  if (existsSync(downPath)) {
@@ -489,18 +579,18 @@ export async function rollback(
489
579
  // rollback may fail if auto-rolled-back
490
580
  }
491
581
  const msg = err instanceof Error ? err.message : String(err);
492
- console.error(` Rollback failed for ${migration.name}: ${msg}`);
582
+ console.error(` Rollback failed for ${migration.migration_name}: ${msg}`);
493
583
  // Still remove the record so the migration can be re-applied
494
584
  }
495
585
  }
496
586
  } else {
497
- console.warn(` Warning: No .down.sql file found for ${migration.name} — skipping SQL execution`);
587
+ console.warn(` Warning: No .down.sql file found for ${migration.migration_name} — skipping SQL execution`);
498
588
  }
499
589
 
500
- await removeMigrationRecord(migration.name);
590
+ await removeMigrationRecord(migration.migration_name);
501
591
  // Return the down-migration file that was run (e.g. "name.down.sql"), matching
502
592
  // the Python master's rollback return form.
503
- rolledBack.push(`${migration.name}.down.sql`);
593
+ rolledBack.push(`${migration.migration_name}.down.sql`);
504
594
  }
505
595
 
506
596
  return rolledBack;
@@ -509,10 +599,10 @@ export async function rollback(
509
599
  /**
510
600
  * Get all applied migrations.
511
601
  */
512
- export async function getAppliedMigrations(): Promise<Array<{ id: number; name: string; batch: number; applied_at: string }>> {
602
+ export async function getAppliedMigrations(): Promise<Array<{ id: number; migration_name: string; description: string; batch: number; executed_at: string; passed: number }>> {
513
603
  const adapter = getAdapter();
514
- return adapterQuery<{ id: number; name: string; batch: number; applied_at: string }>(adapter,
515
- `SELECT * FROM "${MIGRATION_TABLE}" ORDER BY id ASC`,
604
+ return adapterQuery<{ id: number; migration_name: string; description: string; batch: number; executed_at: string; passed: number }>(adapter,
605
+ `SELECT * FROM "${MIGRATION_TABLE}" WHERE passed = 1 ORDER BY id ASC`,
516
606
  );
517
607
  }
518
608
 
@@ -568,6 +658,27 @@ export function normalizeQuotes(sql: string): string {
568
658
  return sql.replace(SMART_QUOTE_RE, (ch) => SMART_QUOTES[ch]);
569
659
  }
570
660
 
661
+ const SET_TERM_RE = /^SET\s+TERM\s+(\S+)$/i;
662
+
663
+ /**
664
+ * Return the new terminator from a `SET TERM <new> <current>` directive.
665
+ *
666
+ * `SET TERM` is a script-level directive (recognised by isql and other
667
+ * InterBase/Firebird tooling, not run by the engine) that changes the
668
+ * terminator separating statements. Recognising it lets a statement whose own
669
+ * body contains the default `;` terminator — a trigger, stored procedure or
670
+ * `EXECUTE BLOCK` — be kept intact rather than split on those inner `;`. The
671
+ * terminator may be more than one character (e.g. `!!`).
672
+ *
673
+ * @param statement A single, already-trimmed statement.
674
+ * @returns The new terminator, or `null` when `statement` is not a `SET TERM`
675
+ * directive.
676
+ */
677
+ export function parseSetTerm(statement: string): string | null {
678
+ const m = statement.trim().match(SET_TERM_RE);
679
+ return m ? m[1] : null;
680
+ }
681
+
571
682
  /**
572
683
  * Split SQL text into individual statements with a single-pass, quote- and
573
684
  * comment-aware scanner. The split decision is made character by character so
@@ -586,7 +697,11 @@ export function normalizeQuotes(sql: string): string {
586
697
  * - `'…'` single-quoted strings and `"…"` double-quoted identifiers are copied
587
698
  * verbatim, honouring the SQL doubled-quote escape (`''` / `""`); a `;`, `--`
588
699
  * or `/*` inside a literal is data, not a delimiter or comment.
589
- * Mirrors the tina4-python `_split_statements` / tina4-php scanner (parity).
700
+ * - A `SET TERM <new> <current>` directive switches the active terminator and is
701
+ * consumed (never emitted), so a statement whose own body contains the default
702
+ * terminator — a Firebird trigger, stored procedure or `EXECUTE BLOCK` —
703
+ * survives as one. Multi-character terminators (e.g. `!!`) are supported.
704
+ * Mirrors the tina4-python `_split_statements` / tina4-php / tina4-ruby scanner (parity).
590
705
  */
591
706
  export function splitStatements(sql: string, delimiter = ";"): string[] {
592
707
  // Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
@@ -596,7 +711,10 @@ export function splitStatements(sql: string, delimiter = ";"): string[] {
596
711
  const statements: string[] = [];
597
712
  let current = "";
598
713
  const n = sql.length;
599
- const dlen = delimiter.length;
714
+ // Active statement terminator. Starts as the delimiter argument; a SET TERM
715
+ // directive can switch it mid-script (dlen recomputed) so a statement whose
716
+ // own body contains the default terminator stays intact.
717
+ let dlen = delimiter.length;
600
718
  let i = 0;
601
719
  let inDollarBlock = false;
602
720
  let inSlashBlock = false;
@@ -685,12 +803,22 @@ export function splitStatements(sql: string, delimiter = ";"): string[] {
685
803
  continue;
686
804
  }
687
805
 
688
- // Statement delimiter — only reached outside blocks/comments/strings.
806
+ // Statement delimiter — only reached outside blocks/comments/strings. A
807
+ // SET TERM directive switches the active terminator and is consumed (never
808
+ // emitted); any other completed statement is collected.
689
809
  if (dlen > 0 && sql.startsWith(delimiter, i)) {
810
+ i += dlen;
690
811
  const stmt = current.trim();
691
- if (stmt) statements.push(stmt);
692
812
  current = "";
693
- i += dlen;
813
+ if (stmt) {
814
+ const newTerm = parseSetTerm(stmt);
815
+ if (newTerm !== null) {
816
+ delimiter = newTerm;
817
+ dlen = delimiter.length;
818
+ } else {
819
+ statements.push(stmt);
820
+ }
821
+ }
694
822
  continue;
695
823
  }
696
824
 
@@ -698,8 +826,10 @@ export function splitStatements(sql: string, delimiter = ";"): string[] {
698
826
  i += 1;
699
827
  }
700
828
 
829
+ // Trailing statement (may not end with a delimiter). A trailing SET TERM
830
+ // directive is a no-op — consume it, don't emit it.
701
831
  const stmt = current.trim();
702
- if (stmt) statements.push(stmt);
832
+ if (stmt && parseSetTerm(stmt) === null) statements.push(stmt);
703
833
  return statements;
704
834
  }
705
835
 
@@ -777,59 +907,10 @@ export async function migrate(
777
907
  return result;
778
908
  }
779
909
 
780
- // Ensure tracking table with batch support
781
- if (!(await adapterTableExists(db, MIGRATION_TABLE))) {
782
- if (isFirebirdAdapter(db)) {
783
- // Firebird: no AUTOINCREMENT, no TEXT type, use generator for IDs
784
- try {
785
- await adapterExecute(db, "CREATE GENERATOR GEN_TINA4_MIGRATION_ID");
786
- try { await adapterExecute(db, "COMMIT"); } catch { /* ignore */ }
787
- } catch {
788
- // Generator may already exist
789
- }
790
- await adapterExecute(db, `CREATE TABLE "${MIGRATION_TABLE}" (
791
- id INTEGER NOT NULL PRIMARY KEY,
792
- name VARCHAR(500) NOT NULL,
793
- batch INTEGER NOT NULL DEFAULT 1,
794
- applied_at VARCHAR(50) NOT NULL
795
- )`);
796
- } else {
797
- // Engine-aware bookkeeping DDL (non-Firebird). Each engine spells an
798
- // auto-increment integer PK differently — SQLite AUTOINCREMENT, Postgres
799
- // SERIAL, MySQL AUTO_INCREMENT, MSSQL IDENTITY(1,1) — so the id column is
800
- // built per engine (raw `AUTOINCREMENT` is a syntax error on every other
801
- // engine; that is the bug that made `migrate()` unusable on
802
- // Postgres/MySQL/MSSQL). The `name` column is VARCHAR (not TEXT) so it can
803
- // carry a UNIQUE index on MySQL; `applied_at` is DATETIME on MSSQL
804
- // (TIMESTAMP there is rowversion, not a real timestamp). SQLite gives
805
- // VARCHAR TEXT affinity, so SQLite behaviour is byte-for-byte unchanged.
806
- // MSSQL has no IF NOT EXISTS (already guarded by the tableExists check
807
- // above), so it is omitted; the other engines tolerate it being absent.
808
- // applied_at keeps a CURRENT_TIMESTAMP default so the recordMigration()
809
- // path (which inserts only name+batch) still works — preserving the prior
810
- // engine-aware createTable behaviour that supplied that default.
811
- const idCol = migrationIdColumn(db);
812
- const engine = engineOf(db);
813
- const ifNotExists = engine === "mssql" ? "" : "IF NOT EXISTS ";
814
- const appliedType = engine === "mssql" ? "DATETIME" : "TEXT";
815
- await adapterExecute(db, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
816
- ${idCol},
817
- name VARCHAR(500) NOT NULL,
818
- batch INTEGER NOT NULL DEFAULT 1,
819
- applied_at ${appliedType} NOT NULL DEFAULT CURRENT_TIMESTAMP
820
- )`);
821
- }
822
- } else {
823
- // Migrate old schema: if table has 'description' + 'passed' columns, migrate data
824
- try {
825
- await adapterQuery<Record<string, unknown>>(db,
826
- `SELECT * FROM "${MIGRATION_TABLE}" LIMIT 0`,
827
- );
828
- // Check column names by querying pragma or just try adding batch
829
- } catch {
830
- // ignore
831
- }
832
- }
910
+ // Ensure the canonical tracking table exists (creates it, or upgrades an
911
+ // older <= 3.13.54 `name`/`applied_at` table in place). Shared with
912
+ // ensureMigrationTable() so both paths produce the identical schema.
913
+ await ensureMigrationTableOn(db);
833
914
 
834
915
  // Collect .sql files (exclude .down.sql), numeric-aware sorted (9_ before 10_).
835
916
  const files = sortMigrationFiles(
@@ -846,7 +927,7 @@ export async function migrate(
846
927
  let currentBatch = 1;
847
928
  try {
848
929
  const batchRows = await adapterQuery<{ max_batch: number | null }>(db,
849
- `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}"`,
930
+ `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
850
931
  );
851
932
  currentBatch = (batchRows[0]?.max_batch ?? 0) + 1;
852
933
  } catch {
@@ -857,33 +938,17 @@ export async function migrate(
857
938
  for (const file of files) {
858
939
  const migrationId = file.replace(/\.sql$/, "");
859
940
 
860
- // Check if already applied support both 'name' and legacy 'description' column
941
+ // Applied iff a row exists with passed = 1. ensureMigrationTableOn() above
942
+ // guarantees the canonical migration_name column (fresh or upgraded).
861
943
  let alreadyApplied = false;
862
944
  try {
863
945
  const existing = await adapterQuery<{ id: number }>(db,
864
- `SELECT id FROM "${MIGRATION_TABLE}" WHERE name = ?`,
946
+ `SELECT id FROM "${MIGRATION_TABLE}" WHERE migration_name = ? AND passed = 1`,
865
947
  [migrationId],
866
948
  );
867
949
  alreadyApplied = existing.length > 0;
868
950
  } catch {
869
- // Might be old schema with 'description' column instead of 'name'
870
- try {
871
- const existing = await adapterQuery<{ id: number; passed: number }>(db,
872
- `SELECT id, passed FROM "${MIGRATION_TABLE}" WHERE description = ?`,
873
- [migrationId],
874
- );
875
- if (existing.length > 0 && existing[0].passed === 1) {
876
- alreadyApplied = true;
877
- } else if (existing.length > 0 && existing[0].passed === 0) {
878
- // Failed record from old schema — remove to retry
879
- await adapterExecute(db,
880
- `DELETE FROM "${MIGRATION_TABLE}" WHERE description = ?`,
881
- [migrationId],
882
- );
883
- }
884
- } catch {
885
- // Neither column exists — continue
886
- }
951
+ alreadyApplied = false;
887
952
  }
888
953
 
889
954
  if (alreadyApplied) {
@@ -916,30 +981,24 @@ export async function migrate(
916
981
  await adapterExecute(db, stmt);
917
982
  }
918
983
 
919
- // Record as applied with batch number
984
+ // Record as applied (passed = 1) with the canonical columns and an
985
+ // explicit ISO-8601 executed_at timestamp.
920
986
  const now = new Date().toISOString();
921
- try {
922
- if (isFirebirdAdapter(db)) {
923
- // Firebird: generate ID from sequence
924
- const idRows = await adapterQuery<{ NEXT_ID: number }>(db,
925
- "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE",
926
- );
927
- const nextId = idRows[0]?.NEXT_ID ?? 1;
928
- await adapterExecute(db,
929
- `INSERT INTO "${MIGRATION_TABLE}" (id, name, batch, applied_at) VALUES (?, ?, ?, ?)`,
930
- [nextId, migrationId, currentBatch, now],
931
- );
932
- } else {
933
- await adapterExecute(db,
934
- `INSERT INTO "${MIGRATION_TABLE}" (name, batch, applied_at) VALUES (?, ?, ?)`,
935
- [migrationId, currentBatch, now],
936
- );
937
- }
938
- } catch {
939
- // Old schema fallback — try description/content/passed columns
987
+ const description = deriveDescription(migrationId);
988
+ if (isFirebirdAdapter(db)) {
989
+ // Firebird: generate ID from sequence
990
+ const idRows = await adapterQuery<{ NEXT_ID: number }>(db,
991
+ "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE",
992
+ );
993
+ const nextId = idRows[0]?.NEXT_ID ?? 1;
994
+ await adapterExecute(db,
995
+ `INSERT INTO "${MIGRATION_TABLE}" (id, migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?, 1)`,
996
+ [nextId, migrationId, description, currentBatch, now],
997
+ );
998
+ } else {
940
999
  await adapterExecute(db,
941
- `INSERT INTO "${MIGRATION_TABLE}" (description, content, passed, run_at) VALUES (?, ?, 1, ?)`,
942
- [migrationId, sqlContent, now],
1000
+ `INSERT INTO "${MIGRATION_TABLE}" (migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, 1)`,
1001
+ [migrationId, description, currentBatch, now],
943
1002
  );
944
1003
  }
945
1004
 
@@ -997,32 +1056,27 @@ export async function status(
997
1056
  return result;
998
1057
  }
999
1058
 
1059
+ // The table exists — upgrade an older <= 3.13.54 shape in place so the
1060
+ // canonical migration_name column is readable below (never creates a table
1061
+ // here; the tableExists check above already returned for the absent case).
1062
+ await ensureMigrationTableOn(db);
1063
+
1000
1064
  // Collect .sql files (exclude .down.sql)
1001
1065
  const files = sortMigrationFiles(
1002
1066
  readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")),
1003
1067
  );
1004
1068
 
1005
- // Get all applied migration names from the DB
1069
+ // Get all applied migration names (passed = 1) from the DB.
1006
1070
  const appliedNames = new Set<string>();
1007
1071
  try {
1008
- const rows = await adapterQuery<{ name: string }>(db,
1009
- `SELECT name FROM "${MIGRATION_TABLE}"`,
1072
+ const rows = await adapterQuery<{ migration_name: string }>(db,
1073
+ `SELECT migration_name FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
1010
1074
  );
1011
1075
  for (const row of rows) {
1012
- appliedNames.add(row.name);
1076
+ if (row.migration_name) appliedNames.add(row.migration_name);
1013
1077
  }
1014
1078
  } catch {
1015
- // Old schema with 'description' column
1016
- try {
1017
- const rows = await adapterQuery<{ description: string; passed: number }>(db,
1018
- `SELECT description, passed FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
1019
- );
1020
- for (const row of rows) {
1021
- appliedNames.add(row.description);
1022
- }
1023
- } catch {
1024
- // No valid tracking — treat all as pending
1025
- }
1079
+ // No valid tracking treat all as pending
1026
1080
  }
1027
1081
 
1028
1082
  for (const file of files) {