tina4-nodejs 3.13.54 → 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 +2 -2
- package/package.json +1 -1
- package/packages/orm/src/migration.ts +183 -169
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
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.
|
|
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
|
@@ -272,89 +272,179 @@ function buildAddColumnSql(
|
|
|
272
272
|
const MIGRATION_TABLE = "tina4_migration";
|
|
273
273
|
|
|
274
274
|
/**
|
|
275
|
-
*
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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(
|
|
284
|
-
try { await adapterExecute(
|
|
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(
|
|
317
|
+
await adapterExecute(db, `CREATE TABLE "${MIGRATION_TABLE}" (
|
|
289
318
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
290
|
-
|
|
319
|
+
migration_name VARCHAR(500) NOT NULL UNIQUE,
|
|
320
|
+
description VARCHAR(500),
|
|
291
321
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
292
|
-
|
|
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)
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// there
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
const
|
|
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
|
-
|
|
307
|
-
await adapterExecute(adapter, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
|
|
336
|
+
await adapterExecute(db, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
|
|
308
337
|
${idCol},
|
|
309
|
-
|
|
338
|
+
migration_name VARCHAR(500) NOT NULL UNIQUE,
|
|
339
|
+
description VARCHAR(500),
|
|
310
340
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
311
|
-
|
|
341
|
+
executed_at VARCHAR(50) NOT NULL,
|
|
342
|
+
passed INTEGER NOT NULL DEFAULT 1
|
|
312
343
|
)`);
|
|
313
344
|
}
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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}" (
|
|
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;
|
|
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;
|
|
403
|
-
`SELECT id,
|
|
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
|
|
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.
|
|
542
|
+
const down = downFunctions.get(migration.migration_name);
|
|
453
543
|
if (down) {
|
|
454
544
|
await down();
|
|
455
545
|
}
|
|
456
|
-
await removeMigrationRecord(migration.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
587
|
+
console.warn(` Warning: No .down.sql file found for ${migration.migration_name} — skipping SQL execution`);
|
|
498
588
|
}
|
|
499
589
|
|
|
500
|
-
await removeMigrationRecord(migration.
|
|
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.
|
|
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;
|
|
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;
|
|
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
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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,
|
|
982
|
-
[migrationId,
|
|
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<{
|
|
1049
|
-
`SELECT
|
|
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.
|
|
1076
|
+
if (row.migration_name) appliedNames.add(row.migration_name);
|
|
1053
1077
|
}
|
|
1054
1078
|
} catch {
|
|
1055
|
-
//
|
|
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) {
|