tina4-nodejs 3.13.38 → 3.13.40

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
  };