tina4-nodejs 3.13.38 → 3.13.39

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.
@@ -95,6 +95,16 @@ export class BaseModel {
95
95
  /** Relationship cache for lazy loading */
96
96
  private _relCache: Record<string, unknown> = {};
97
97
 
98
+ /**
99
+ * Cause of the most recent failed save(). null when the last save()
100
+ * succeeded. Mirrors db.getError() so a caller that checks
101
+ * `if (!(await model.save()))` can still recover the real cause via
102
+ * `model.getError()` / `model.lastError` — the failure never vanishes
103
+ * silently. Set by save() (validation message or driver error), cleared
104
+ * to null on a successful save.
105
+ */
106
+ lastError: string | null = null;
107
+
98
108
  constructor(data?: Record<string, unknown> | string) {
99
109
  // Accept a JSON object string (parity with Python/PHP/Ruby):
100
110
  // new Widget('{"id":1,"name":"alpha"}')
@@ -110,6 +120,21 @@ export class BaseModel {
110
120
  `Map over the list to build many records.`,
111
121
  );
112
122
  }
123
+ const ModelClass0 = this.constructor as typeof BaseModel;
124
+ // Set defaults from field definitions BEFORE populating from data.
125
+ // Outlier A (mirrors Python issue #50.1): a callable default is resolved
126
+ // to its called value PER INSTANCE, so per-row defaults (e.g.
127
+ // `default: () => new Date()`) actually differ and a function never
128
+ // reaches the driver. Static defaults are assigned verbatim. Data passed
129
+ // to the constructor overrides any default below.
130
+ const fields0 = ModelClass0.fields ?? {};
131
+ for (const [name, def] of Object.entries(fields0)) {
132
+ if (def.default === undefined) continue;
133
+ this[name] = typeof def.default === "function"
134
+ ? (def.default as () => unknown)()
135
+ : def.default;
136
+ }
137
+
113
138
  if (data) {
114
139
  const ModelClass = this.constructor as typeof BaseModel;
115
140
  // If autoMap is on, auto-generate fieldMapping from camelCase fields
@@ -188,8 +213,12 @@ export class BaseModel {
188
213
  this.belongsTo.push({ model: def.references, foreignKey: key });
189
214
  }
190
215
 
191
- // Register hasMany on the referenced model via the module-level registry
192
- const hasManyKey = def.relatedName ?? (this.tableName ?? this.name.toLowerCase());
216
+ // Register hasMany on the referenced model via the module-level registry.
217
+ // Outlier F: the has-many key defaults to the DECLARING class name
218
+ // lowercased + "s" (Python master: `name.lower() + "s"`), e.g. a Post
219
+ // with author_id → Author.posts. The relatedName override wins. The old
220
+ // default was the table name, which drifted from the documented rule.
221
+ const hasManyKey = def.relatedName ?? (this.name.toLowerCase() + "s");
193
222
  const existing = _fkRegistry.get(def.references) ?? [];
194
223
  if (!existing.find((r) => r.foreignKey === key && r.declaringModel === this.name)) {
195
224
  existing.push({ foreignKey: key, declaringModel: this.name, hasManyKey });
@@ -207,7 +236,10 @@ export class BaseModel {
207
236
  for (const entry of entries) {
208
237
  this.hasMany = this.hasMany ?? [];
209
238
  if (!this.hasMany.find((r) => r.foreignKey === entry.foreignKey && r.model === entry.declaringModel)) {
210
- this.hasMany.push({ model: entry.declaringModel, foreignKey: entry.foreignKey });
239
+ // Outlier F: carry the derived has-many key (declaring class lowercased
240
+ // + "s", or the relatedName override) onto the relationship so an
241
+ // include: ["posts"] resolves to it — not the related table name.
242
+ this.hasMany.push({ model: entry.declaringModel, foreignKey: entry.foreignKey, relatedName: entry.hasManyKey });
211
243
  }
212
244
  }
213
245
  }
@@ -300,38 +332,83 @@ export class BaseModel {
300
332
  /**
301
333
  * Create a new instance from data, save it, and return the saved instance.
302
334
  *
335
+ * Canonical #3: if the underlying save() fails (validation errors or a
336
+ * driver error), create() returns `false` — it does NOT hand back a
337
+ * possibly-unsaved instance, so a failed insert can never masquerade as a
338
+ * success. The failure cause is logged and available on the (discarded)
339
+ * instance's getError() via the same path save() uses.
340
+ *
303
341
  * Usage:
304
342
  * const user = User.create({ name: "Alice", email: "alice@example.com" });
343
+ * if (!(await User.create({ name: null }))) { ... } // save() failed -> false
305
344
  */
306
345
  static async create<T extends BaseModel>(
307
346
  this: new (data?: Record<string, unknown>) => T,
308
347
  data: Record<string, unknown> = {},
309
- ): Promise<T> {
348
+ ): Promise<T | false> {
310
349
  const instance = new this(data) as T;
311
- await instance.save();
350
+ if ((await instance.save()) === false) {
351
+ return false;
352
+ }
312
353
  return instance;
313
354
  }
314
355
 
315
356
  /**
316
- * Find records by filter dict. Always returns an array.
357
+ * Find record(s) by primary key, filter object, or all.
317
358
  *
318
- * Usage:
319
- * User.find({ name: "Alice" }) → [User, ...]
320
- * User.find({ age: 18 }, 10) [User, ...] (limit 10)
321
- * User.find({}, 100, 0, "name ASC") → [User, ...] (with orderBy)
322
- * User.find() all records
359
+ * Outlier C — overloaded on the first argument (parity with
360
+ * Python/PHP/Ruby):
361
+ * - number | string (scalar PK) single instance (or null), like
362
+ * findById(pk). `include` is accepted as the 2nd argument in this form.
363
+ * - object (filter) array of instances (AND-ed conditions).
364
+ * - omitted → array of all records.
323
365
  *
324
- * Use findById(id) for single-record primary key lookup.
366
+ * Usage:
367
+ * User.find(1) → User | null (PK lookup)
368
+ * User.find(1, ["posts"]) → User | null (PK lookup + eager)
369
+ * User.find({ name: "Alice" }) → [User, ...]
370
+ * User.find({ age: 18 }, 10) → [User, ...] (limit 10)
371
+ * User.find({}, 100, 0, "name ASC") → [User, ...] (with orderBy)
372
+ * User.find() → all records
325
373
  */
374
+ // Scalar PK → single instance | null.
375
+ static async find<T extends BaseModel>(
376
+ this: new (data?: Record<string, unknown>) => T,
377
+ pk: number | string,
378
+ include?: string[],
379
+ ): Promise<T | null>;
380
+ // Filter object / all → array.
326
381
  static async find<T extends BaseModel>(
327
382
  this: new (data?: Record<string, unknown>) => T,
328
383
  filter?: Record<string, unknown>,
329
- limit = 100,
384
+ limit?: number,
385
+ offset?: number,
386
+ orderBy?: string,
387
+ include?: string[],
388
+ ): Promise<T[]>;
389
+ static async find<T extends BaseModel>(
390
+ this: new (data?: Record<string, unknown>) => T,
391
+ filter?: Record<string, unknown> | number | string,
392
+ limit: number | string[] = 100,
330
393
  offset = 0,
331
394
  orderBy?: string,
332
395
  include?: string[],
333
- ): Promise<T[]> {
396
+ ): Promise<T[] | T | null> {
334
397
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
398
+
399
+ // Scalar PK lookup routes to findById. A number or a string (but NOT a
400
+ // boolean, and NOT an object) is a primary-key value — Active Record
401
+ // convention (Django Model.objects.get(pk), SQLAlchemy session.get(M, id),
402
+ // Ruby Model.find(1)). In the scalar form the 2nd arg is `include`.
403
+ if (typeof filter === "number" || typeof filter === "string") {
404
+ const inc = Array.isArray(limit) ? limit : undefined;
405
+ return (ModelClass.findById as (id: unknown, include?: string[]) => Promise<T | null>).call(
406
+ ModelClass, filter, inc,
407
+ );
408
+ }
409
+
410
+ // Array form — coerce `limit` back to a number for the list path.
411
+ const lim = typeof limit === "number" ? limit : 100;
335
412
  const db = ModelClass.getDb();
336
413
  const conditions: string[] = [];
337
414
  const params: unknown[] = [];
@@ -356,7 +433,7 @@ export class BaseModel {
356
433
  sql += ` ORDER BY ${orderBy}`;
357
434
  }
358
435
 
359
- const rows = await adapterFetch(db, sql, params, limit, offset);
436
+ const rows = await adapterFetch(db, sql, params, lim, offset);
360
437
  const data = (rows as any)?.data ?? rows;
361
438
  const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
362
439
  const inst = new this(row) as T;
@@ -391,11 +468,16 @@ export class BaseModel {
391
468
 
392
469
  let sql: string;
393
470
  if (filter === undefined || filter === null) {
394
- // No args — use PK already set
395
- const pk = (ModelClass as any).primaryKey ?? (this as any).primaryKey ?? "id";
396
- const pkValue = (this as any)[pk];
471
+ // No args — use the PK value already set. Outlier B: resolve the REAL
472
+ // primary key via getPkField()/getPkColumn() (a model has no
473
+ // `primaryKey` static the old code referenced a non-existent field, so
474
+ // it always queried `WHERE undefined = ?` and never loaded). Use the JS
475
+ // property for the value and the DB column for the WHERE clause.
476
+ const pkProp = ModelClass.getPkField();
477
+ const pkCol = ModelClass.getPkColumn();
478
+ const pkValue = (this as any)[pkProp];
397
479
  if (pkValue === undefined || pkValue === null) return false;
398
- sql = `SELECT * FROM ${table} WHERE ${pk} = ?`;
480
+ sql = `SELECT * FROM "${table}" WHERE "${pkCol}" = ?`;
399
481
  params = [pkValue];
400
482
  } else {
401
483
  sql = `SELECT * FROM ${table} WHERE ${filter}`;
@@ -492,11 +574,40 @@ export class BaseModel {
492
574
  }
493
575
 
494
576
  /**
495
- * Save this instance (insert or update).
496
- * Returns this on success (fluent), null on failure.
577
+ * Save this instance (insert or update). Returns this on success (fluent
578
+ * self), false on failure.
579
+ *
580
+ * Fails loud, never silent (the same principle db.execute() follows by
581
+ * raising). On ANY failure path save() returns `false` — keeping the
582
+ * contract callers rely on (`if (!(await model.save())) ...`) — but it also
583
+ * (a) logs the real cause via Log.error with model/table context and
584
+ * (b) records the cause on `this.lastError` so a caller can recover it after
585
+ * the fact via getError() / lastError. It never throws and never changes the
586
+ * `this | false` return shape.
587
+ *
588
+ * Two distinct failure paths, both loud:
589
+ * - Validation (canonical #2): validate() runs FIRST. If it returns errors,
590
+ * save() logs them, records them on lastError, and returns false WITHOUT
591
+ * touching the database — an invalid model never reaches the driver.
592
+ * - Database: a driver error (NOT NULL, duplicate PK, missing table, ...) is
593
+ * rolled back, logged with the underlying cause, recorded on lastError,
594
+ * and returns false — the cause is no longer swallowed silently.
497
595
  */
498
596
  async save(): Promise<this | false> {
499
597
  const ModelClass = this.constructor as typeof BaseModel;
598
+
599
+ // ── Canonical #2: validate() is enforced. An invalid model never reaches
600
+ // the driver — fail loud (log + lastError), return false. ──
601
+ const errors = this.validate();
602
+ if (errors.length > 0) {
603
+ this.lastError = errors.join("; ");
604
+ Log.error(
605
+ `${ModelClass.name}.save() refused: validation failed for table ` +
606
+ `'${ModelClass.tableName}' — ${this.lastError}`,
607
+ );
608
+ return false;
609
+ }
610
+
500
611
  const db = ModelClass.getDb();
501
612
  const pk = ModelClass.getPkField();
502
613
  const pkCol = ModelClass.getPkColumn();
@@ -585,14 +696,42 @@ export class BaseModel {
585
696
  }
586
697
  }
587
698
  await adapterCommit(db);
588
- } catch (e) {
699
+ } catch (e: any) {
589
700
  await adapterRollback(db);
701
+ // ── Canonical #1: fail loud, never silent. Keep the false return
702
+ // contract, but capture the REAL cause (prefer the adapter's
703
+ // getError()/getLastError() when present, falling back to the exception
704
+ // text) on this.lastError so it survives, and log it with model/table
705
+ // context. ──
706
+ const adapterErr =
707
+ typeof (db as any).getError === "function" ? (db as any).getError() :
708
+ typeof (db as any).getLastError === "function" ? (db as any).getLastError() :
709
+ null;
710
+ this.lastError = adapterErr || e?.message || String(e);
711
+ Log.error(
712
+ `${ModelClass.name}.save() failed for table ` +
713
+ `'${ModelClass.tableName}': ${this.lastError}`,
714
+ );
590
715
  return false;
591
716
  }
717
+ // Success — clear any previously-recorded error.
718
+ this.lastError = null;
592
719
  (this as any)._exists = true;
593
720
  return this;
594
721
  }
595
722
 
723
+ /**
724
+ * Return the cause of the most recent failed save(), or null.
725
+ *
726
+ * Mirrors db.getError(). After save() returns false — whether from
727
+ * validation or a driver error — the real cause is retrievable here (and on
728
+ * this.lastError) so a caller using the `if (!(await model.save()))`
729
+ * contract can still surface it. Cleared to null on a successful save.
730
+ */
731
+ getError(): string | null {
732
+ return this.lastError;
733
+ }
734
+
596
735
  /**
597
736
  * Delete this instance. Uses soft delete if configured.
598
737
  */
@@ -1249,9 +1388,15 @@ export class BaseModel {
1249
1388
  const base = r.model.toLowerCase();
1250
1389
  const related = BaseModel._modelRegistry[r.model];
1251
1390
  const table = related?.tableName?.toLowerCase();
1391
+ // Outlier F: an FK-auto-wired has-many carries its derived key
1392
+ // (declaring class lowercased + "s", or relatedName) — match it so
1393
+ // include: ["posts"] resolves to the wired relation regardless of the
1394
+ // related table name.
1395
+ const rel = r.relatedName?.toLowerCase();
1252
1396
  return (
1253
1397
  base === want ||
1254
1398
  base + "s" === want ||
1399
+ (rel !== undefined && rel === want) ||
1255
1400
  (table !== undefined && table === want)
1256
1401
  );
1257
1402
  };
@@ -39,6 +39,10 @@ export {
39
39
  createMigration,
40
40
  status,
41
41
  Migration,
42
+ splitStatements,
43
+ normalizeQuotes,
44
+ sortMigrationFiles,
45
+ shouldSkipCreateTable,
42
46
  } from "./migration.js";
43
47
  export type { MigrationResult, MigrationStatus } from "./migration.js";
44
48
  export { AutoCrud, generateCrudRoutes } from "./autoCrud.js";
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { Log } from "@tina4/core";
3
4
  import type { FieldDefinition, DatabaseAdapter } from "./types.js";
4
5
  import type { SQLiteAdapter } from "./adapters/sqlite.js";
5
6
  import type { DiscoveredModel } from "./model.js";
@@ -76,6 +77,55 @@ async function shouldSkipForFirebird(
76
77
  return null;
77
78
  }
78
79
 
80
+ /**
81
+ * Match CREATE TABLE <name> — name may be quoted ("x"), bracketed ([x] MSSQL),
82
+ * or bare. Captures the table name.
83
+ */
84
+ const CREATE_TABLE_RE =
85
+ /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|\[([^\]]+)\]|(\w+))/i;
86
+
87
+ /**
88
+ * Identify the database engine via the adapter's class name.
89
+ * (constructor.name is the engine discriminator used throughout this package.)
90
+ */
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";
96
+ }
97
+
98
+ /**
99
+ * Make CREATE TABLE idempotent on engines lacking IF NOT EXISTS.
100
+ *
101
+ * Firebird and MSSQL do not support `CREATE TABLE IF NOT EXISTS`, so a raw
102
+ * CREATE in a re-run migration raises "object already exists". When the target
103
+ * table already exists on those engines, return a skip reason so the statement
104
+ * is skipped (mirrors the Firebird ALTER-TABLE-ADD idempotency guard).
105
+ * SQLite/MySQL/PostgreSQL support IF NOT EXISTS and are left to the engine.
106
+ * Only a genuine already-exists is skipped — every other error still raises.
107
+ */
108
+ export async function shouldSkipCreateTable(
109
+ db: DatabaseAdapter,
110
+ stmt: string,
111
+ ): Promise<string | null> {
112
+ const engine = engineOf(db);
113
+ if (engine !== "firebird" && engine !== "mssql") return null;
114
+
115
+ const m = stmt.match(CREATE_TABLE_RE);
116
+ if (!m) return null;
117
+
118
+ const table = m[1] ?? m[2] ?? m[3];
119
+ try {
120
+ if (await adapterTableExists(db, table)) {
121
+ return `Table ${table} already exists, skipping CREATE TABLE`;
122
+ }
123
+ } catch {
124
+ return null;
125
+ }
126
+ return null;
127
+ }
128
+
79
129
  /**
80
130
  * Sync model definitions to the database (create tables, add columns).
81
131
  */
@@ -411,13 +461,44 @@ export interface MigrationStatus {
411
461
  pending: string[];
412
462
  }
413
463
 
464
+ /**
465
+ * Smart/curly quotes — editors, word processors, docs and chat apps silently
466
+ * convert a straight " to “ ” and a straight ' to ‘ ’ (plus primes ′ ″). Those
467
+ * characters are NOT valid SQL string/identifier delimiters, so a pasted-in
468
+ * migration fails to run ("syntax error near …"). Map them back to straight
469
+ * ASCII quotes. (Real string CONTENTS are unaffected by intent — we only swap
470
+ * the lookalike code points for their ASCII equivalents.)
471
+ */
472
+ const SMART_QUOTES: Record<string, string> = {
473
+ // Double-quote lookalikes → straight " (U+0022)
474
+ "“": '"', "”": '"', "„": '"', "‟": '"', "″": '"',
475
+ // Single-quote / apostrophe lookalikes → straight ' (U+0027)
476
+ "‘": "'", "’": "'", "‚": "'", "‛": "'", "′": "'",
477
+ };
478
+ const SMART_QUOTE_RE = new RegExp(`[${Object.keys(SMART_QUOTES).join("")}]`, "g");
479
+
480
+ /**
481
+ * Replace smart/curly quotes with straight ASCII quotes so migration SQL
482
+ * authored or pasted from an editor/doc actually runs (those code points are
483
+ * not valid SQL delimiters). Already-straight quotes and ordinary string
484
+ * content are returned byte-for-byte unchanged.
485
+ */
486
+ export function normalizeQuotes(sql: string): string {
487
+ return sql.replace(SMART_QUOTE_RE, (ch) => SMART_QUOTES[ch]);
488
+ }
489
+
414
490
  /**
415
491
  * Split SQL text into individual statements on the given delimiter.
416
492
  *
417
493
  * Strips line comments (`-- ...`) and block comments, handles stored
418
494
  * procedure blocks delimited by `$$` or `//`.
419
495
  */
420
- function splitStatements(sql: string, delimiter = ";"): string[] {
496
+ export function splitStatements(sql: string, delimiter = ";"): string[] {
497
+ // Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
498
+ // an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs. Mirrors
499
+ // Python's _split_statements applying _normalize_quotes as its first line.
500
+ sql = normalizeQuotes(sql);
501
+
421
502
  // Extract blocks delimited by $$ or // first, replacing with placeholders
422
503
  const blocks: string[] = [];
423
504
  const saveBlock = (_match: string, _p1: string): string => {
@@ -426,7 +507,12 @@ function splitStatements(sql: string, delimiter = ";"): string[] {
426
507
  };
427
508
 
428
509
  let processed = sql.replace(/\$\$([\s\S]*?)\$\$/g, saveBlock);
429
- processed = processed.replace(/\/\/([\s\S]*?)\/\//g, saveBlock);
510
+ // The `//` delimiters must NOT be preceded by a colon, so a URL scheme
511
+ // (`https://…`) or other `://` literal inside a migration is never captured
512
+ // as an opaque stored-proc block (it would otherwise swallow everything
513
+ // between two `//` occurrences and skip statement splitting/cleaning).
514
+ // Negative lookbehind `(?<!:)` mirrors Python's runner.
515
+ processed = processed.replace(/(?<!:)\/\/([\s\S]*?)(?<!:)\/\//g, saveBlock);
430
516
 
431
517
  // Remove block comments (/* ... */)
432
518
  const clean = processed.replace(/\/\*[\s\S]*?\*\//g, "");
@@ -458,25 +544,43 @@ function splitStatements(sql: string, delimiter = ";"): string[] {
458
544
  * - Sequential: 000001_name.sql, 000002_name.sql
459
545
  * - Timestamp: 20240315120000_name.sql (YYYYMMDDHHMMSS)
460
546
  *
461
- * Both patterns start with digits followed by underscore, so alphabetical
462
- * sort works correctly for both (zero-padded sequential and timestamp).
547
+ * Numeric-aware: a file with a leading numeric/timestamp prefix sorts first by
548
+ * that number (so `9_*` applies before `10_*` a plain lexical sort misorders
549
+ * unpadded prefixes because "10" < "9"). Files with NO numeric prefix sort
550
+ * AFTER the numbered ones, then lexically. Mirrors Python's `_migration_sort_key`.
463
551
  */
464
- function sortMigrationFiles(files: string[]): string[] {
552
+ export function sortMigrationFiles(files: string[]): string[] {
553
+ // Returns a tuple-like key: [group, numeric, name].
554
+ // group 0 = has numeric prefix (sorts first); group 1 = no prefix (sorts after).
555
+ const key = (name: string): [number, bigint, string] => {
556
+ const m = name.match(/^(\d+)/);
557
+ return m ? [0, BigInt(m[1]), name] : [1, 0n, name];
558
+ };
465
559
  return [...files].sort((a, b) => {
466
- const aPrefix = a.match(/^(\d+)/);
467
- const bPrefix = b.match(/^(\d+)/);
468
- if (aPrefix && bPrefix) {
469
- // Compare numeric prefixes handles both 000001 and 20240315120000
470
- const aNum = BigInt(aPrefix[1]);
471
- const bNum = BigInt(bPrefix[1]);
472
- if (aNum < bNum) return -1;
473
- if (aNum > bNum) return 1;
474
- return a.localeCompare(b);
475
- }
476
- return a.localeCompare(b);
560
+ const [ag, an, anm] = key(a);
561
+ const [bg, bn, bnm] = key(b);
562
+ if (ag !== bg) return ag - bg;
563
+ if (an < bn) return -1;
564
+ if (an > bn) return 1;
565
+ return anm.localeCompare(bnm);
477
566
  });
478
567
  }
479
568
 
569
+ /**
570
+ * Warn (once) about migration filenames without a recognized NNNNNN_/timestamp
571
+ * prefix — their ordering relative to numbered migrations is undefined, a silent
572
+ * out-of-order-apply footgun. Mirrors Python's runner.
573
+ */
574
+ function warnUnprefixedMigrations(files: string[]): void {
575
+ const unprefixed = files.filter((f) => !/^\d+[_-]/.test(f));
576
+ if (unprefixed.length > 0) {
577
+ Log.warning(
578
+ "Migration file(s) without a numeric/timestamp prefix may apply out of order: " +
579
+ unprefixed.join(", "),
580
+ );
581
+ }
582
+ }
583
+
480
584
  /**
481
585
  * Run all pending SQL-file migrations.
482
586
  *
@@ -533,7 +637,19 @@ export async function migrate(
533
637
  batch INTEGER NOT NULL DEFAULT 1,
534
638
  applied_at TEXT NOT NULL
535
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
+ });
536
651
  } else {
652
+ // SQLite / MySQL — both support IF NOT EXISTS + AUTOINCREMENT/AUTO_INCREMENT.
537
653
  await adapterExecute(db, `CREATE TABLE IF NOT EXISTS "${MIGRATION_TABLE}" (
538
654
  id INTEGER PRIMARY KEY AUTOINCREMENT,
539
655
  name TEXT NOT NULL,
@@ -553,13 +669,17 @@ export async function migrate(
553
669
  }
554
670
  }
555
671
 
556
- // Collect .sql files (exclude .down.sql), sorted by prefix
672
+ // Collect .sql files (exclude .down.sql), numeric-aware sorted (9_ before 10_).
557
673
  const files = sortMigrationFiles(
558
674
  readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")),
559
675
  );
560
676
 
561
677
  if (files.length === 0) return result;
562
678
 
679
+ // Warn about files without a recognized numeric/timestamp prefix — their
680
+ // ordering relative to numbered migrations is undefined.
681
+ warnUnprefixedMigrations(files);
682
+
563
683
  // Determine the batch number for this run
564
684
  let currentBatch = 1;
565
685
  try {
@@ -621,10 +741,12 @@ export async function migrate(
621
741
  await adapterStartTransaction(db);
622
742
 
623
743
  for (const stmt of statements) {
624
- // Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
625
- // Pre-check the system catalogue so duplicate columns are
626
- // silently skipped instead of raising an error.
627
- const skipReason = await shouldSkipForFirebird(db, stmt);
744
+ // Idempotency on engines lacking IF NOT EXISTS: Firebird ALTER-TABLE-ADD,
745
+ // and CREATE TABLE on Firebird/MSSQL. Pre-check the catalogue so a genuine
746
+ // already-exists is skipped instead of raising every other error still
747
+ // raises (rolls the file back).
748
+ const skipReason =
749
+ (await shouldSkipForFirebird(db, stmt)) ?? (await shouldSkipCreateTable(db, stmt));
628
750
  if (skipReason) {
629
751
  console.log(` Migration ${file}: ${skipReason}`);
630
752
  continue;
@@ -671,7 +793,12 @@ export async function migrate(
671
793
  const msg = err instanceof Error ? err.message : String(err);
672
794
  console.error(` Migration failed: ${file} — ${msg}`);
673
795
  result.failed.push(file);
674
- // Continue to next file (matching Python behaviour)
796
+ // STOP at the first failure (parity with Python/PHP/Ruby). The file rolled
797
+ // back; later migrations must NOT be applied on top of a missing earlier
798
+ // one (a missing column / table would cascade silent corruption). Already-
799
+ // applied files stay applied — fix the bad file and re-run.
800
+ Log.error(`Migration stopped at first failure: ${file} — ${msg}`);
801
+ break;
675
802
  }
676
803
  }
677
804
 
@@ -428,8 +428,20 @@ export class QueryBuilder {
428
428
  return [{ [field]: { [mongoOp]: val } }, paramIndex + 1];
429
429
  }
430
430
 
431
- // Fallback
432
- return [{ $where: cond }, paramIndex];
431
+ // Canonical #5: no silent $where fallback. Previously an unparseable
432
+ // condition was wrapped as `{ $where: <raw condition string> }` — a raw-JS
433
+ // sink that is both injection-shaped (the WHERE string runs as JavaScript
434
+ // on the MongoDB server) and silently different semantics from the SQL the
435
+ // caller wrote. Fail loud instead: name the clause so the caller fixes it
436
+ // rather than shipping a surprise $where.
437
+ throw new Error(
438
+ `QueryBuilder.toMongo(): cannot translate WHERE clause to a MongoDB ` +
439
+ `filter: "${cond}". Supported forms: "<field> <op> ?" ` +
440
+ `(=, !=, <>, >, >=, <, <=), "<field> LIKE ?", ` +
441
+ `"<field> [NOT] IN (?)", "<field> IS [NOT] NULL". Rewrite the ` +
442
+ `condition in one of those forms (toMongo() will not silently emit a ` +
443
+ `raw $where JavaScript expression).`,
444
+ );
433
445
  }
434
446
 
435
447
  /**
@@ -20,6 +20,13 @@ export interface FieldDefinition {
20
20
  export interface RelationshipDefinition {
21
21
  model: string;
22
22
  foreignKey: string;
23
+ /**
24
+ * The relationship accessor/include name on the OWNING model. For an
25
+ * FK-auto-wired has-many this is the declaring class name lowercased + "s"
26
+ * (Python master rule) or the `relatedName` override. Used by eager-load
27
+ * include resolution so an `include: ["posts"]` matches the wired relation.
28
+ */
29
+ relatedName?: string;
23
30
  }
24
31
 
25
32
  export interface ModelDefinition {
@@ -86,6 +86,20 @@ export function validate(
86
86
  errors.push({ field: name, message: "must be a valid date/time" });
87
87
  }
88
88
  break;
89
+
90
+ case "foreignKey": {
91
+ // Outlier D: previously there was no foreignKey case, so ANY value
92
+ // passed validation silently. A foreign key references another model's
93
+ // primary key — by default an auto-increment integer — so validate it
94
+ // as an integer (a numeric string like "12" is coerced and accepted).
95
+ // This catches the common bug of assigning a whole object / array /
96
+ // non-numeric string to an *_id column before it reaches the driver.
97
+ const fkNum = typeof value === "string" ? Number(value) : value;
98
+ if (typeof fkNum !== "number" || isNaN(fkNum) || !Number.isInteger(fkNum)) {
99
+ errors.push({ field: name, message: "must be a valid foreign key (integer)" });
100
+ }
101
+ break;
102
+ }
89
103
  }
90
104
  }
91
105