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 +2 -2
- package/package.json +1 -1
- package/packages/orm/src/index.ts +1 -0
- package/packages/orm/src/migration.ts +229 -175
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
|
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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,
|
|
942
|
-
[migrationId,
|
|
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<{
|
|
1009
|
-
`SELECT
|
|
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.
|
|
1076
|
+
if (row.migration_name) appliedNames.add(row.migration_name);
|
|
1013
1077
|
}
|
|
1014
1078
|
} catch {
|
|
1015
|
-
//
|
|
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) {
|