tina4-nodejs 3.13.42 → 3.13.44

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.
@@ -68,7 +68,10 @@ export interface PostgresConfig {
68
68
 
69
69
  export class PostgresAdapter implements DatabaseAdapter {
70
70
  private client: InstanceType<typeof import("pg").Client> | null = null;
71
- private _lastInsertId: number | bigint | null = null;
71
+ // string is included for non-integer primary keys: a UUID PK (the common
72
+ // `id uuid PRIMARY KEY DEFAULT gen_random_uuid()` shape) returns its id as a
73
+ // 36-char string via RETURNING, not a SERIAL integer (#256).
74
+ private _lastInsertId: number | bigint | string | null = null;
72
75
 
73
76
  constructor(private config: PostgresConfig | string) {}
74
77
 
@@ -115,16 +118,20 @@ export class PostgresAdapter implements DatabaseAdapter {
115
118
 
116
119
  /**
117
120
  * Normalise an `id` column value (typed `unknown` because pg row values are
118
- * `unknown`) into the `number | bigint | null` shape `_lastInsertId` /
119
- * `DatabaseResult.lastInsertId` expect. At runtime PG returns numeric PKs as
120
- * number/bigint (the int8/numeric type parsers above coerce them to Number);
121
- * a numeric string is coerced, anything else (null/undefined/non-numeric)
122
- * becomes null.
121
+ * `unknown`) into the shape `_lastInsertId` / `DatabaseResult.lastInsertId`
122
+ * expect. At runtime PG returns numeric PKs as number/bigint (the int8/numeric
123
+ * type parsers above coerce them to Number); a numeric string is coerced to a
124
+ * number so the SERIAL path always returns the integer id.
125
+ *
126
+ * A non-numeric string id — the UUID PK case (`id uuid PRIMARY KEY DEFAULT
127
+ * gen_random_uuid()`) returned via RETURNING — is preserved as-is so the
128
+ * insert surfaces the actual id instead of null (#256). null/undefined/empty
129
+ * still become null.
123
130
  */
124
- private normalizeId(value: unknown): number | bigint | null {
131
+ private normalizeId(value: unknown): number | bigint | string | null {
125
132
  if (typeof value === "number" || typeof value === "bigint") return value;
126
- if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
127
- return Number(value);
133
+ if (typeof value === "string" && value.trim() !== "") {
134
+ return Number.isNaN(Number(value)) ? value : Number(value);
128
135
  }
129
136
  return null;
130
137
  }
@@ -202,12 +209,33 @@ export class PostgresAdapter implements DatabaseAdapter {
202
209
  return rows[0] ?? null;
203
210
  }
204
211
 
205
- insert(table: string, data: Record<string, unknown>): DatabaseResult {
212
+ insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
206
213
  throw new Error("Use insertAsync() for PostgreSQL.");
207
214
  }
208
215
 
209
- async insertAsync(table: string, data: Record<string, unknown>): Promise<DatabaseResult> {
216
+ async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
210
217
  this.ensureConnected();
218
+ // A list of dicts is a batch insert — build one parameterised INSERT and run
219
+ // it once per row via executeManyAsync (ONE connection, wrapped in a single
220
+ // transaction). Database.insert / the docs advertise `data: object | object[]`;
221
+ // without this branch a list called Object.keys() on the array — `["0","1",…]`
222
+ // — producing garbage SQL (mirrors the Python `'list' has no attribute keys`
223
+ // crash this fix addresses).
224
+ if (Array.isArray(data)) {
225
+ if (data.length === 0) return { success: true, rowsAffected: 0 };
226
+ const keys = Object.keys(data[0]);
227
+ const placeholders = keys.map(() => "?").join(", ");
228
+ const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
229
+ const paramsList = data.map((row) => keys.map((k) => row[k]));
230
+ try {
231
+ const result = await this.executeManyAsync(sql, paramsList);
232
+ if (result.lastInsertId !== undefined) this._lastInsertId = result.lastInsertId;
233
+ return { success: true, rowsAffected: result.totalAffected, lastInsertId: result.lastInsertId };
234
+ } catch (e) {
235
+ return { success: false, rowsAffected: 0, error: (e as Error).message };
236
+ }
237
+ }
238
+
211
239
  const keys = Object.keys(data);
212
240
  const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
213
241
  const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders}) RETURNING *`;
@@ -337,7 +365,7 @@ export class PostgresAdapter implements DatabaseAdapter {
337
365
  }));
338
366
  }
339
367
 
340
- lastInsertId(): number | bigint | null {
368
+ lastInsertId(): number | bigint | string | null {
341
369
  return this._lastInsertId;
342
370
  }
343
371
 
@@ -18,9 +18,23 @@ function isIdentifier(str: string): boolean {
18
18
  */
19
19
  type SqlParam = null | number | bigint | string | NodeJS.ArrayBufferView;
20
20
 
21
- /** Narrow adapter-level `unknown[]` params to node:sqlite's bindable type. */
21
+ /**
22
+ * Coerce adapter-level `unknown[]` params to node:sqlite's bindable shape.
23
+ *
24
+ * node:sqlite only binds null/number/bigint/string/ArrayBufferView and REJECTS a
25
+ * raw JS boolean ("Provided value cannot be bound to SQLite parameter N"). SQLite
26
+ * stores booleans as INTEGER 0/1 (fieldTypeToSQLite maps "boolean" -> INTEGER, and
27
+ * boolean defaults already emit 1/0), so coerce here at the single bind boundary —
28
+ * every write path (execute/query/fetchOne/insert/update/delete and ORM save())
29
+ * funnels through this. booleans -> 0/1, Date -> ISO-8601 string, undefined -> null.
30
+ */
22
31
  function toSqlParams(params: readonly unknown[]): SqlParam[] {
23
- return params as SqlParam[];
32
+ return params.map((p) => {
33
+ if (typeof p === "boolean") return p ? 1 : 0;
34
+ if (p === undefined) return null;
35
+ if (p instanceof Date) return p.toISOString();
36
+ return p as SqlParam;
37
+ });
24
38
  }
25
39
 
26
40
  /**
@@ -64,6 +64,19 @@ export class AutoCrud {
64
64
  }
65
65
  }
66
66
 
67
+ /**
68
+ * Filter discovered models down to those that explicitly opted into auto-CRUD via
69
+ * `static autoCrud = true` (the documented opt-in gate; default false). The server
70
+ * passes only these to generateCrudRoutes, so a model without the flag gets no CRUD
71
+ * endpoints. Exported so the opt-in gate is locked in by a test rather than
72
+ * re-implemented at each call site.
73
+ */
74
+ export function crudEligibleModels(models: DiscoveredModel[]): DiscoveredModel[] {
75
+ return models.filter(
76
+ (m) => (m.modelClass as { autoCrud?: boolean } | undefined)?.autoCrud === true,
77
+ );
78
+ }
79
+
67
80
  /**
68
81
  * Generate CRUD route definitions for the given models.
69
82
  * (Standalone function for backward compatibility.)
@@ -679,7 +679,9 @@ export class BaseModel {
679
679
  // the caller; don't overwrite it with the driver's last_id.
680
680
  if (pkField?.autoIncrement) {
681
681
  // RETURNING result: pg puts it in rows[0][pkCol]; normalise here.
682
- let newId = extractLastInsertId(result);
682
+ // string is allowed for a non-integer PK surfaced by lastInsertId()
683
+ // (e.g. a PostgreSQL UUID PK) — #256.
684
+ let newId: number | bigint | string | null = extractLastInsertId(result);
683
685
  if (newId === null && result && typeof result === "object") {
684
686
  const rows = (result as any).rows;
685
687
  if (Array.isArray(rows) && rows[0]) {
@@ -397,7 +397,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
397
397
  return this.adapter.columns(table);
398
398
  }
399
399
 
400
- lastInsertId(): number | bigint | null {
400
+ lastInsertId(): number | bigint | string | null {
401
401
  return this.adapter.lastInsertId();
402
402
  }
403
403
 
@@ -519,6 +519,19 @@ export class Database {
519
519
  this.adapter = adapter;
520
520
  }
521
521
 
522
+ /**
523
+ * Set the engine type ("sqlite" | "postgres" | "mysql" | "mssql" |
524
+ * "firebird" | "mongodb"). The static `Database.create` factory assigns the
525
+ * private `dbType` directly; `initDatabase()` (a free function, no private
526
+ * access) routes through this setter so a URL connection is correctly typed.
527
+ * Without it a `postgres://` connection kept the `"sqlite"` default and
528
+ * `getNextId()` took the SQLite branch — hitting the non-existent
529
+ * `tina4_sequences` table on PostgreSQL instead of native sequences (#255).
530
+ */
531
+ setDbType(type: string): void {
532
+ this.dbType = type;
533
+ }
534
+
522
535
  /**
523
536
  * Async factory: creates a Database from a connection URL.
524
537
  * Works with all adapter types (sqlite, postgres, mysql, mssql, firebird).
@@ -755,8 +768,8 @@ export class Database {
755
768
  }
756
769
  }
757
770
 
758
- /** Insert a row into a table. */
759
- async insert(table: string, data: Record<string, unknown>): Promise<DatabaseWriteResult> {
771
+ /** Insert one row (object) or a batch of rows (array of objects) into a table. */
772
+ async insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseWriteResult> {
760
773
  const adapter = this.getNextAdapter();
761
774
  const result = (adapter as any).insertAsync
762
775
  ? await (adapter as any).insertAsync(table, data)
@@ -1434,11 +1447,19 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1434
1447
  if (pool > 0) {
1435
1448
  // Pool-aware path — delegate to Database.create which manages
1436
1449
  // round-robin adapter rotation and async-local-storage transaction pinning.
1437
- // Database.create already exposes the global; exposeDb here is idempotent.
1450
+ // Database.create already sets dbType + exposes the global; exposeDb here
1451
+ // is idempotent.
1438
1452
  return exposeDb(await Database.create(url, resolvedUser, resolvedPassword, pool));
1439
1453
  }
1454
+ // Single-connection URL path. Parse the URL so the engine type is known and
1455
+ // set it on the Database — otherwise dbType keeps its "sqlite" default and a
1456
+ // postgres://… connection takes the SQLite getNextId branch, crashing on the
1457
+ // missing tina4_sequences table instead of using native sequences (#255).
1458
+ const parsed = parseDatabaseUrl(url, resolvedUser, resolvedPassword);
1440
1459
  const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
1441
- return exposeDb(new Database(setAdapter(adapter)));
1460
+ const db = new Database(setAdapter(adapter));
1461
+ db.setDbType(parsed.type);
1462
+ return exposeDb(db);
1442
1463
  }
1443
1464
 
1444
1465
  // Legacy config path — normalize "sqlserver" to "mssql"
@@ -1459,11 +1480,21 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1459
1480
  );
1460
1481
  }
1461
1482
 
1483
+ // Legacy config-object path. As with the URL path above, the constructed
1484
+ // Database must be told its engine type — otherwise dbType keeps its "sqlite"
1485
+ // default and a `{ type: "postgres" }` connection takes the SQLite getNextId
1486
+ // branch and crashes on the missing tina4_sequences table (#255).
1487
+ const finished = (adapter: DatabaseAdapter): Database => {
1488
+ const db = new Database(setAdapter(adapter));
1489
+ db.setDbType(type);
1490
+ return exposeDb(db);
1491
+ };
1492
+
1462
1493
  switch (type) {
1463
1494
  case "sqlite": {
1464
1495
  const { SQLiteAdapter } = await import("./adapters/sqlite.js");
1465
1496
  const adapter = new SQLiteAdapter(config?.path ?? "./data/tina4.db");
1466
- return exposeDb(new Database(setAdapter(adapter)));
1497
+ return finished(adapter);
1467
1498
  }
1468
1499
  case "postgres": {
1469
1500
  const { PostgresAdapter } = await import("./adapters/postgres.js");
@@ -1475,7 +1506,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1475
1506
  database: config?.database,
1476
1507
  });
1477
1508
  await adapter.connect();
1478
- return exposeDb(new Database(setAdapter(adapter)));
1509
+ return finished(adapter);
1479
1510
  }
1480
1511
  case "mysql": {
1481
1512
  const { MysqlAdapter } = await import("./adapters/mysql.js");
@@ -1487,7 +1518,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1487
1518
  database: config?.database,
1488
1519
  });
1489
1520
  await adapter.connect();
1490
- return exposeDb(new Database(setAdapter(adapter)));
1521
+ return finished(adapter);
1491
1522
  }
1492
1523
  case "mssql": {
1493
1524
  const { MssqlAdapter } = await import("./adapters/mssql.js");
@@ -1499,7 +1530,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1499
1530
  database: config?.database,
1500
1531
  });
1501
1532
  await adapter.connect();
1502
- return exposeDb(new Database(setAdapter(adapter)));
1533
+ return finished(adapter);
1503
1534
  }
1504
1535
  case "firebird": {
1505
1536
  const { FirebirdAdapter } = await import("./adapters/firebird.js");
@@ -1511,7 +1542,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1511
1542
  database: config?.database,
1512
1543
  });
1513
1544
  await adapter.connect();
1514
- return exposeDb(new Database(setAdapter(adapter)));
1545
+ return finished(adapter);
1515
1546
  }
1516
1547
  case "mongodb": {
1517
1548
  const { MongodbAdapter } = await import("./adapters/mongodb.js");
@@ -1524,14 +1555,14 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1524
1555
  const connectionString = `mongodb://${creds}${host}:${port}/${database}`;
1525
1556
  const adapter = new MongodbAdapter(connectionString);
1526
1557
  await adapter.connect();
1527
- return exposeDb(new Database(setAdapter(adapter)));
1558
+ return finished(adapter);
1528
1559
  }
1529
1560
  case "odbc": {
1530
1561
  const { OdbcAdapter } = await import("./adapters/odbc.js");
1531
1562
  const connStr = config?.connectionString ?? config?.url?.replace(/^odbc:\/\/\//, "") ?? "";
1532
1563
  const adapter = new OdbcAdapter({ connectionString: connStr });
1533
1564
  await adapter.connect();
1534
- return exposeDb(new Database(setAdapter(adapter)));
1565
+ return finished(adapter);
1535
1566
  }
1536
1567
  default:
1537
1568
  throw new Error(`Unknown database type: ${type}`);
@@ -45,7 +45,7 @@ export {
45
45
  shouldSkipCreateTable,
46
46
  } from "./migration.js";
47
47
  export type { MigrationResult, MigrationStatus } from "./migration.js";
48
- export { AutoCrud, generateCrudRoutes } from "./autoCrud.js";
48
+ export { AutoCrud, generateCrudRoutes, crudEligibleModels } from "./autoCrud.js";
49
49
  export { buildQuery, parseQueryString } from "./query.js";
50
50
  export { validate } from "./validation.js";
51
51
  export type { ValidationError } from "./validation.js";
@@ -28,15 +28,41 @@ const ALTER_ADD_RE =
28
28
  /^\s*ALTER\s+TABLE\s+(?:"([^"]+)"|(\S+))\s+ADD\s+(?:"([^"]+)"|(\S+))/i;
29
29
 
30
30
  /**
31
- * Check if the adapter is a Firebird adapter (duck-type check).
32
- * We look for the `queryAsync` method and `translateSql` which are
33
- * unique to the Firebird adapter.
31
+ * Check if the adapter is a Firebird adapter.
32
+ *
33
+ * Detection is by the adapter's class name (`constructor.name`) — the engine
34
+ * discriminator used throughout this package (see `engineOf`). A previous
35
+ * duck-type check (`queryAsync` + `translateSql`) was WRONG: every async
36
+ * adapter (Postgres/MySQL/MSSQL) exposes BOTH of those, so the Postgres
37
+ * adapter was mis-identified as Firebird. That routed the migration-tracking
38
+ * CREATE into the Firebird `CREATE GENERATOR` + no-default-id DDL and the
39
+ * record INSERT into the Firebird `GEN_ID()` path — both invalid on Postgres,
40
+ * so `migrate()` died on the FIRST migration with "syntax error" / aborted
41
+ * transaction. Class-name detection fixes the mis-route without altering the
42
+ * runner loop, transaction handling, or raise/return semantics.
43
+ */
44
+ /**
45
+ * Unwrap a CachedDatabaseAdapter (what initDatabase() returns) to the concrete
46
+ * underlying adapter. Engine detection keys on constructor.name, which on the
47
+ * wrapper reads "CachedDatabaseAdapter" — so without unwrapping, every real app
48
+ * (all of which go through initDatabase) is mis-detected as SQLite and migrate()
49
+ * emits AUTOINCREMENT on Postgres/MySQL/MSSQL. Drills through any nesting; a raw
50
+ * adapter passes through unchanged.
34
51
  */
52
+ function unwrapAdapter(db: DatabaseAdapter): DatabaseAdapter {
53
+ let cur: unknown = db;
54
+ while (
55
+ cur &&
56
+ (cur as { constructor?: { name?: string } }).constructor?.name === "CachedDatabaseAdapter" &&
57
+ (cur as { adapter?: unknown }).adapter
58
+ ) {
59
+ cur = (cur as { adapter: unknown }).adapter;
60
+ }
61
+ return cur as DatabaseAdapter;
62
+ }
63
+
35
64
  function isFirebirdAdapter(db: DatabaseAdapter): boolean {
36
- return (
37
- typeof (db as any).queryAsync === "function" &&
38
- typeof (db as any).translateSql === "function"
39
- );
65
+ return unwrapAdapter(db).constructor.name === "FirebirdAdapter";
40
66
  }
41
67
 
42
68
  /**
@@ -87,12 +113,41 @@ const CREATE_TABLE_RE =
87
113
  /**
88
114
  * Identify the database engine via the adapter's class name.
89
115
  * (constructor.name is the engine discriminator used throughout this package.)
116
+ *
117
+ * SQLite is the default for any unrecognized adapter (matches the rest of the
118
+ * package and Python's `db.get_database_type() or "sqlite"`).
90
119
  */
91
- function engineOf(db: DatabaseAdapter): "firebird" | "mssql" | "other" {
92
- const name = db.constructor.name;
93
- if (name === "FirebirdAdapter") return "firebird";
94
- if (name === "MssqlAdapter") return "mssql";
95
- return "other";
120
+ function engineOf(
121
+ db: DatabaseAdapter,
122
+ ): "firebird" | "mssql" | "postgres" | "mysql" | "sqlite" {
123
+ switch (unwrapAdapter(db).constructor.name) {
124
+ case "FirebirdAdapter": return "firebird";
125
+ case "MssqlAdapter": return "mssql";
126
+ case "PostgresAdapter": return "postgres";
127
+ case "MysqlAdapter": return "mysql";
128
+ default: return "sqlite";
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Engine-aware auto-increment integer primary-key column for the
134
+ * `tina4_migration` tracking table.
135
+ *
136
+ * Each engine spells an auto-increment integer PK differently — SQLite uses
137
+ * AUTOINCREMENT, PostgreSQL SERIAL, MySQL AUTO_INCREMENT, MSSQL IDENTITY(1,1).
138
+ * Emitting raw `AUTOINCREMENT` on any non-SQLite engine fails with
139
+ * "syntax error at or near AUTOINCREMENT" — which is exactly why `migrate()`
140
+ * was unusable on PostgreSQL/MySQL/MSSQL. Mirrors the engine-aware id DDL in
141
+ * the adapters' createTableAsync and Python's `_create_v3_table`. (Firebird is
142
+ * handled by its own generator branch and never reaches here.)
143
+ */
144
+ function migrationIdColumn(db: DatabaseAdapter): string {
145
+ switch (engineOf(db)) {
146
+ case "postgres": return "id SERIAL PRIMARY KEY";
147
+ case "mysql": return "id INTEGER PRIMARY KEY AUTO_INCREMENT";
148
+ case "mssql": return "id INTEGER IDENTITY(1,1) PRIMARY KEY";
149
+ default: return "id INTEGER PRIMARY KEY AUTOINCREMENT";
150
+ }
96
151
  }
97
152
 
98
153
  /**
@@ -192,7 +247,7 @@ function buildAddColumnSql(
192
247
  colName: string,
193
248
  def: FieldDefinition,
194
249
  ): string {
195
- const engine = adapter.constructor.name;
250
+ const engine = unwrapAdapter(adapter).constructor.name;
196
251
  const isPg = engine === "PostgresAdapter";
197
252
  const typeMap: Record<string, string> = isPg
198
253
  ? { integer: "INTEGER", string: def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)", text: "TEXT", number: "DOUBLE PRECISION", numeric: "DOUBLE PRECISION", boolean: "BOOLEAN", datetime: "TIMESTAMP" }
@@ -237,12 +292,24 @@ export async function ensureMigrationTable(): Promise<void> {
237
292
  applied_at VARCHAR(50) NOT NULL
238
293
  )`);
239
294
  } else {
240
- await adapterCreateTable(adapter, MIGRATION_TABLE, {
241
- id: { type: "integer", primaryKey: true, autoIncrement: true },
242
- name: { type: "string", required: true },
243
- batch: { type: "integer", required: true },
244
- applied_at: { type: "datetime", default: "now" },
245
- });
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);
305
+ const ifNotExists = engine === "mssql" ? "" : "IF NOT EXISTS ";
306
+ const appliedType = engine === "mssql" ? "DATETIME" : "TEXT";
307
+ await adapterExecute(adapter, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
308
+ ${idCol},
309
+ name VARCHAR(500) NOT NULL,
310
+ batch INTEGER NOT NULL DEFAULT 1,
311
+ applied_at ${appliedType} NOT NULL DEFAULT CURRENT_TIMESTAMP
312
+ )`);
246
313
  }
247
314
  } else {
248
315
  // Ensure batch column exists on older tables that only had passed/description
@@ -360,7 +427,17 @@ export async function removeMigrationRecord(name: string): Promise<void> {
360
427
  *
361
428
  * @param migrationsDir - Directory containing migration files (default: "migrations")
362
429
  * @param delimiter - SQL statement delimiter (default: ";")
363
- * @returns Array of rolled-back migration names
430
+ * @returns Array of the down-migration files that were run, e.g.
431
+ * "000001_create_users.down.sql". (The legacy down-FUNCTION Map API returns the
432
+ * bare migration name instead, since no .down.sql file is involved there.)
433
+ *
434
+ * NOTE on return form (intentional, cross-framework): migration return values reflect
435
+ * WHAT each method acted on, so the forms differ by method and that is by design (not
436
+ * unified). migrate()/getApplied()/getPending() return the up-migration filename
437
+ * ("name.sql"); rollback() returns the DOWN-migration filename it executed
438
+ * ("name.down.sql") — matching the Python master. So a caller diffing rollback()
439
+ * against getApplied() compares ".down.sql" vs ".sql": strip the suffixes (or compare
440
+ * the bare "name" stem) to relate them.
364
441
  */
365
442
  export async function rollback(
366
443
  migrationsDir?: string | Map<string, () => void | Promise<void>>,
@@ -377,6 +454,8 @@ export async function rollback(
377
454
  await down();
378
455
  }
379
456
  await removeMigrationRecord(migration.name);
457
+ // Legacy down-FUNCTION API: no .down.sql file is involved here, so return the
458
+ // bare migration name (the file-based path below returns "name.down.sql").
380
459
  rolledBack.push(migration.name);
381
460
  }
382
461
  return rolledBack;
@@ -419,7 +498,9 @@ export async function rollback(
419
498
  }
420
499
 
421
500
  await removeMigrationRecord(migration.name);
422
- rolledBack.push(migration.name);
501
+ // Return the down-migration file that was run (e.g. "name.down.sql"), matching
502
+ // the Python master's rollback return form.
503
+ rolledBack.push(`${migration.name}.down.sql`);
423
504
  }
424
505
 
425
506
  return rolledBack;
@@ -629,32 +710,30 @@ export async function migrate(
629
710
  batch INTEGER NOT NULL DEFAULT 1,
630
711
  applied_at VARCHAR(50) NOT NULL
631
712
  )`);
632
- } else if (db.constructor.name === "PostgresAdapter") {
633
- // PostgreSQL: SERIAL + TIMESTAMP (not AUTOINCREMENT/TEXT).
634
- await adapterExecute(db, `CREATE TABLE IF NOT EXISTS "${MIGRATION_TABLE}" (
635
- id SERIAL PRIMARY KEY,
636
- name TEXT NOT NULL,
637
- batch INTEGER NOT NULL DEFAULT 1,
638
- applied_at TEXT NOT NULL
639
- )`);
640
- } else if (engineOf(db) === "mssql") {
641
- // MSSQL has no AUTOINCREMENT / IF NOT EXISTS — route the bootstrap
642
- // through the adapter's engine-aware createTable (IDENTITY(1,1) etc.),
643
- // exactly like ensureMigrationTable does. Emitting raw
644
- // AUTOINCREMENT/IF NOT EXISTS here is invalid on SQL Server.
645
- await adapterCreateTable(db, MIGRATION_TABLE, {
646
- id: { type: "integer", primaryKey: true, autoIncrement: true },
647
- name: { type: "string", required: true },
648
- batch: { type: "integer", required: true },
649
- applied_at: { type: "datetime", default: "now" },
650
- });
651
713
  } else {
652
- // SQLite / MySQL both support IF NOT EXISTS + AUTOINCREMENT/AUTO_INCREMENT.
653
- await adapterExecute(db, `CREATE TABLE IF NOT EXISTS "${MIGRATION_TABLE}" (
654
- id INTEGER PRIMARY KEY AUTOINCREMENT,
655
- name TEXT NOT NULL,
714
+ // Engine-aware bookkeeping DDL (non-Firebird). Each engine spells an
715
+ // auto-increment integer PK differently SQLite AUTOINCREMENT, Postgres
716
+ // SERIAL, MySQL AUTO_INCREMENT, MSSQL IDENTITY(1,1) — so the id column is
717
+ // built per engine (raw `AUTOINCREMENT` is a syntax error on every other
718
+ // engine; that is the bug that made `migrate()` unusable on
719
+ // Postgres/MySQL/MSSQL). The `name` column is VARCHAR (not TEXT) so it can
720
+ // carry a UNIQUE index on MySQL; `applied_at` is DATETIME on MSSQL
721
+ // (TIMESTAMP there is rowversion, not a real timestamp). SQLite gives
722
+ // VARCHAR TEXT affinity, so SQLite behaviour is byte-for-byte unchanged.
723
+ // MSSQL has no IF NOT EXISTS (already guarded by the tableExists check
724
+ // above), so it is omitted; the other engines tolerate it being absent.
725
+ // applied_at keeps a CURRENT_TIMESTAMP default so the recordMigration()
726
+ // path (which inserts only name+batch) still works — preserving the prior
727
+ // engine-aware createTable behaviour that supplied that default.
728
+ const idCol = migrationIdColumn(db);
729
+ const engine = engineOf(db);
730
+ const ifNotExists = engine === "mssql" ? "" : "IF NOT EXISTS ";
731
+ const appliedType = engine === "mssql" ? "DATETIME" : "TEXT";
732
+ await adapterExecute(db, `CREATE TABLE ${ifNotExists}"${MIGRATION_TABLE}" (
733
+ ${idCol},
734
+ name VARCHAR(500) NOT NULL,
656
735
  batch INTEGER NOT NULL DEFAULT 1,
657
- applied_at TEXT NOT NULL
736
+ applied_at ${appliedType} NOT NULL DEFAULT CURRENT_TIMESTAMP
658
737
  )`);
659
738
  }
660
739
  } else {
@@ -52,7 +52,9 @@ export interface ColumnInfo {
52
52
  export interface DatabaseResult {
53
53
  success: boolean;
54
54
  rowsAffected: number;
55
- lastInsertId?: number | bigint;
55
+ // string covers a non-integer primary key (e.g. a PostgreSQL UUID PK returns
56
+ // its id as a string via RETURNING, not a SERIAL integer) — #256.
57
+ lastInsertId?: number | bigint | string;
56
58
  error?: string;
57
59
  }
58
60
 
@@ -96,8 +98,8 @@ export interface DatabaseAdapter {
96
98
  /** List columns with types for a table. */
97
99
  columns(table: string): ColumnInfo[];
98
100
 
99
- /** Get the last auto-increment id. */
100
- lastInsertId(): number | bigint | null;
101
+ /** Get the last inserted id (auto-increment integer, or a UUID/string PK). */
102
+ lastInsertId(): number | bigint | string | null;
101
103
 
102
104
  /** Close the connection. */
103
105
  close(): void;