tina4-nodejs 3.13.54 → 3.13.56

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.54)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.56)
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.54 — 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.56 — 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.54",
3
+ "version": "3.13.56",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -179,6 +179,10 @@ export function skillBlock(contextFile: string): string {
179
179
  "- **tina4-js** \u2014 Read `.claude/skills/tina4-js/SKILL.md` for frontend work.",
180
180
  "- **tina4-maintainer** \u2014 Read `.claude/skills/tina4-maintainer/SKILL.md` for framework-level changes.",
181
181
  "",
182
+ "If Tina4 behaves differently from what these skills describe, that is a bug in the skill.",
183
+ "Tell the developer, then report it at https://tina4.com/report-a-skill",
184
+ "(or open an issue on the matching tina4stack/* GitHub repo).",
185
+ "",
182
186
  "See https://tina4.com for full docs.",
183
187
  ].join("\n")
184
188
  : [
@@ -186,6 +190,8 @@ export function skillBlock(contextFile: string): string {
186
190
  " .claude/skills/tina4-developer/SKILL.md (feature development)",
187
191
  " .claude/skills/tina4-js/SKILL.md (frontend / tina4-js)",
188
192
  " .claude/skills/tina4-maintainer/SKILL.md (framework-level changes)",
193
+ "Found a skill that disagrees with how Tina4 actually behaves? Tell the developer,",
194
+ "then report it at https://tina4.com/report-a-skill",
189
195
  "Docs: https://tina4.com",
190
196
  ].join("\n");
191
197
  return `${start}\n${body}\n${end}`;
@@ -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
 
@@ -817,59 +907,10 @@ export async function migrate(
817
907
  return result;
818
908
  }
819
909
 
820
- // Ensure tracking table with batch support
821
- if (!(await adapterTableExists(db, MIGRATION_TABLE))) {
822
- if (isFirebirdAdapter(db)) {
823
- // Firebird: no AUTOINCREMENT, no TEXT type, use generator for IDs
824
- try {
825
- await adapterExecute(db, "CREATE GENERATOR GEN_TINA4_MIGRATION_ID");
826
- try { await adapterExecute(db, "COMMIT"); } catch { /* ignore */ }
827
- } catch {
828
- // Generator may already exist
829
- }
830
- await adapterExecute(db, `CREATE TABLE "${MIGRATION_TABLE}" (
831
- id INTEGER NOT NULL PRIMARY KEY,
832
- name VARCHAR(500) NOT NULL,
833
- batch INTEGER NOT NULL DEFAULT 1,
834
- applied_at VARCHAR(50) NOT NULL
835
- )`);
836
- } else {
837
- // Engine-aware bookkeeping DDL (non-Firebird). Each engine spells an
838
- // auto-increment integer PK differently — SQLite AUTOINCREMENT, Postgres
839
- // SERIAL, MySQL AUTO_INCREMENT, MSSQL IDENTITY(1,1) — so the id column is
840
- // built per engine (raw `AUTOINCREMENT` is a syntax error on every other
841
- // engine; that is the bug that made `migrate()` unusable on
842
- // Postgres/MySQL/MSSQL). The `name` column is VARCHAR (not TEXT) so it can
843
- // carry a UNIQUE index on MySQL; `applied_at` is DATETIME on MSSQL
844
- // (TIMESTAMP there is rowversion, not a real timestamp). SQLite gives
845
- // VARCHAR TEXT affinity, so SQLite behaviour is byte-for-byte unchanged.
846
- // MSSQL has no IF NOT EXISTS (already guarded by the tableExists check
847
- // above), so it is omitted; the other engines tolerate it being absent.
848
- // applied_at keeps a CURRENT_TIMESTAMP default so the recordMigration()
849
- // path (which inserts only name+batch) still works — preserving the prior
850
- // engine-aware createTable behaviour that supplied that default.
851
- const idCol = migrationIdColumn(db);
852
- const engine = engineOf(db);
853
- const ifNotExists = engine === "mssql" ? "" : "IF NOT EXISTS ";
854
- const appliedType = engine === "mssql" ? "DATETIME" : "TEXT";
855
- await adapterExecute(db, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
856
- ${idCol},
857
- name VARCHAR(500) NOT NULL,
858
- batch INTEGER NOT NULL DEFAULT 1,
859
- applied_at ${appliedType} NOT NULL DEFAULT CURRENT_TIMESTAMP
860
- )`);
861
- }
862
- } else {
863
- // Migrate old schema: if table has 'description' + 'passed' columns, migrate data
864
- try {
865
- await adapterQuery<Record<string, unknown>>(db,
866
- `SELECT * FROM "${MIGRATION_TABLE}" LIMIT 0`,
867
- );
868
- // Check column names by querying pragma or just try adding batch
869
- } catch {
870
- // ignore
871
- }
872
- }
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);
873
914
 
874
915
  // Collect .sql files (exclude .down.sql), numeric-aware sorted (9_ before 10_).
875
916
  const files = sortMigrationFiles(
@@ -886,7 +927,7 @@ export async function migrate(
886
927
  let currentBatch = 1;
887
928
  try {
888
929
  const batchRows = await adapterQuery<{ max_batch: number | null }>(db,
889
- `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}"`,
930
+ `SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
890
931
  );
891
932
  currentBatch = (batchRows[0]?.max_batch ?? 0) + 1;
892
933
  } catch {
@@ -897,33 +938,17 @@ export async function migrate(
897
938
  for (const file of files) {
898
939
  const migrationId = file.replace(/\.sql$/, "");
899
940
 
900
- // 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).
901
943
  let alreadyApplied = false;
902
944
  try {
903
945
  const existing = await adapterQuery<{ id: number }>(db,
904
- `SELECT id FROM "${MIGRATION_TABLE}" WHERE name = ?`,
946
+ `SELECT id FROM "${MIGRATION_TABLE}" WHERE migration_name = ? AND passed = 1`,
905
947
  [migrationId],
906
948
  );
907
949
  alreadyApplied = existing.length > 0;
908
950
  } catch {
909
- // Might be old schema with 'description' column instead of 'name'
910
- try {
911
- const existing = await adapterQuery<{ id: number; passed: number }>(db,
912
- `SELECT id, passed FROM "${MIGRATION_TABLE}" WHERE description = ?`,
913
- [migrationId],
914
- );
915
- if (existing.length > 0 && existing[0].passed === 1) {
916
- alreadyApplied = true;
917
- } else if (existing.length > 0 && existing[0].passed === 0) {
918
- // Failed record from old schema — remove to retry
919
- await adapterExecute(db,
920
- `DELETE FROM "${MIGRATION_TABLE}" WHERE description = ?`,
921
- [migrationId],
922
- );
923
- }
924
- } catch {
925
- // Neither column exists — continue
926
- }
951
+ alreadyApplied = false;
927
952
  }
928
953
 
929
954
  if (alreadyApplied) {
@@ -956,30 +981,24 @@ export async function migrate(
956
981
  await adapterExecute(db, stmt);
957
982
  }
958
983
 
959
- // 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.
960
986
  const now = new Date().toISOString();
961
- try {
962
- if (isFirebirdAdapter(db)) {
963
- // Firebird: generate ID from sequence
964
- const idRows = await adapterQuery<{ NEXT_ID: number }>(db,
965
- "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE",
966
- );
967
- const nextId = idRows[0]?.NEXT_ID ?? 1;
968
- await adapterExecute(db,
969
- `INSERT INTO "${MIGRATION_TABLE}" (id, name, batch, applied_at) VALUES (?, ?, ?, ?)`,
970
- [nextId, migrationId, currentBatch, now],
971
- );
972
- } else {
973
- await adapterExecute(db,
974
- `INSERT INTO "${MIGRATION_TABLE}" (name, batch, applied_at) VALUES (?, ?, ?)`,
975
- [migrationId, currentBatch, now],
976
- );
977
- }
978
- } catch {
979
- // 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;
980
994
  await adapterExecute(db,
981
- `INSERT INTO "${MIGRATION_TABLE}" (description, content, passed, run_at) VALUES (?, ?, 1, ?)`,
982
- [migrationId, sqlContent, now],
995
+ `INSERT INTO "${MIGRATION_TABLE}" (id, migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?, 1)`,
996
+ [nextId, migrationId, description, currentBatch, now],
997
+ );
998
+ } else {
999
+ await adapterExecute(db,
1000
+ `INSERT INTO "${MIGRATION_TABLE}" (migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, 1)`,
1001
+ [migrationId, description, currentBatch, now],
983
1002
  );
984
1003
  }
985
1004
 
@@ -1037,32 +1056,27 @@ export async function status(
1037
1056
  return result;
1038
1057
  }
1039
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
+
1040
1064
  // Collect .sql files (exclude .down.sql)
1041
1065
  const files = sortMigrationFiles(
1042
1066
  readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")),
1043
1067
  );
1044
1068
 
1045
- // Get all applied migration names from the DB
1069
+ // Get all applied migration names (passed = 1) from the DB.
1046
1070
  const appliedNames = new Set<string>();
1047
1071
  try {
1048
- const rows = await adapterQuery<{ name: string }>(db,
1049
- `SELECT name FROM "${MIGRATION_TABLE}"`,
1072
+ const rows = await adapterQuery<{ migration_name: string }>(db,
1073
+ `SELECT migration_name FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
1050
1074
  );
1051
1075
  for (const row of rows) {
1052
- appliedNames.add(row.name);
1076
+ if (row.migration_name) appliedNames.add(row.migration_name);
1053
1077
  }
1054
1078
  } catch {
1055
- // Old schema with 'description' column
1056
- try {
1057
- const rows = await adapterQuery<{ description: string; passed: number }>(db,
1058
- `SELECT description, passed FROM "${MIGRATION_TABLE}" WHERE passed = 1`,
1059
- );
1060
- for (const row of rows) {
1061
- appliedNames.add(row.description);
1062
- }
1063
- } catch {
1064
- // No valid tracking — treat all as pending
1065
- }
1079
+ // No valid tracking treat all as pending
1066
1080
  }
1067
1081
 
1068
1082
  for (const file of files) {