tina4-nodejs 3.13.37 → 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.
Files changed (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +66 -44
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. package/packages/swagger/src/ui.ts +1 -1
@@ -9,6 +9,45 @@ function isIdentifier(str: string): boolean {
9
9
  return /^[A-Za-z_][A-Za-z0-9_]*$/.test(str);
10
10
  }
11
11
 
12
+ /**
13
+ * The value shape `node:sqlite` binds as a statement parameter. Mirrors the
14
+ * module-internal `SQLInputValue` type (which is not exported from
15
+ * `node:sqlite`, so it cannot be imported). The DatabaseAdapter interface
16
+ * carries params as `unknown[]`; this narrows them to the bindable shape at the
17
+ * `.run()`/`.all()`/`.get()` call sites without an `any` cast.
18
+ */
19
+ type SqlParam = null | number | bigint | string | NodeJS.ArrayBufferView;
20
+
21
+ /** Narrow adapter-level `unknown[]` params to node:sqlite's bindable type. */
22
+ function toSqlParams(params: readonly unknown[]): SqlParam[] {
23
+ return params as SqlParam[];
24
+ }
25
+
26
+ /**
27
+ * Whether the linked SQLite library is at least the given version.
28
+ *
29
+ * Used to gate `UPDATE ... RETURNING` (SQLite >= 3.35) in the atomic
30
+ * sequence path. Memoised — the version never changes at runtime.
31
+ */
32
+ let _sqliteVersionInfo: [number, number, number] | null = null;
33
+ function sqliteVersionAtLeast(major: number, minor: number, patch: number): boolean {
34
+ if (_sqliteVersionInfo === null) {
35
+ try {
36
+ const probe = new DatabaseSync(":memory:");
37
+ const row = probe.prepare("SELECT sqlite_version() AS v").get() as { v?: string };
38
+ probe.close();
39
+ const parts = String(row?.v ?? "0.0.0").split(".").map((n) => parseInt(n, 10) || 0);
40
+ _sqliteVersionInfo = [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
41
+ } catch {
42
+ _sqliteVersionInfo = [0, 0, 0];
43
+ }
44
+ }
45
+ const [ma, mi, pa] = _sqliteVersionInfo;
46
+ if (ma !== major) return ma > major;
47
+ if (mi !== minor) return mi > minor;
48
+ return pa >= patch;
49
+ }
50
+
12
51
  /**
13
52
  * Resolve a SQLite path argument against the project root (cwd).
14
53
  *
@@ -55,7 +94,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
55
94
 
56
95
  execute(sql: string, params?: unknown[]): unknown {
57
96
  const stmt = this.db.prepare(sql);
58
- const result = params ? stmt.run(...params) : stmt.run();
97
+ const result = params ? stmt.run(...toSqlParams(params)) : stmt.run();
59
98
  if (result && typeof result === "object" && "lastInsertRowid" in result) {
60
99
  this._lastInsertId = result.lastInsertRowid as number | bigint;
61
100
  }
@@ -70,8 +109,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
70
109
  this.db.exec("BEGIN TRANSACTION");
71
110
  try {
72
111
  for (const params of paramsList) {
73
- const result = stmt.run(...params);
74
- totalAffected += result.changes;
112
+ const result = stmt.run(...toSqlParams(params));
113
+ totalAffected += Number(result.changes);
75
114
  if (result.lastInsertRowid) {
76
115
  lastId = result.lastInsertRowid;
77
116
  this._lastInsertId = result.lastInsertRowid;
@@ -88,7 +127,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
88
127
 
89
128
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
90
129
  const stmt = this.db.prepare(sql);
91
- return (params ? stmt.all(...params) : stmt.all()) as T[];
130
+ return (params ? stmt.all(...toSqlParams(params)) : stmt.all()) as T[];
92
131
  }
93
132
 
94
133
  fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
@@ -108,7 +147,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
108
147
 
109
148
  fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
110
149
  const stmt = this.db.prepare(sql);
111
- const row = params ? stmt.get(...params) : stmt.get();
150
+ const row = params ? stmt.get(...toSqlParams(params)) : stmt.get();
112
151
  return (row as T) ?? null;
113
152
  }
114
153
 
@@ -129,9 +168,9 @@ export class SQLiteAdapter implements DatabaseAdapter {
129
168
  const values = Object.values(data);
130
169
 
131
170
  try {
132
- const result = this.db.prepare(sql).run(...values);
171
+ const result = this.db.prepare(sql).run(...toSqlParams(values));
133
172
  this._lastInsertId = result.lastInsertRowid;
134
- return { success: true, rowsAffected: result.changes, lastInsertId: result.lastInsertRowid };
173
+ return { success: true, rowsAffected: Number(result.changes), lastInsertId: result.lastInsertRowid };
135
174
  } catch (e) {
136
175
  return { success: false, rowsAffected: 0, error: (e as Error).message };
137
176
  }
@@ -144,8 +183,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
144
183
  const values = [...Object.values(data), ...Object.values(filter)];
145
184
 
146
185
  try {
147
- const result = this.db.prepare(sql).run(...values);
148
- return { success: true, rowsAffected: result.changes };
186
+ const result = this.db.prepare(sql).run(...toSqlParams(values));
187
+ return { success: true, rowsAffected: Number(result.changes) };
149
188
  } catch (e) {
150
189
  return { success: false, rowsAffected: 0, error: (e as Error).message };
151
190
  }
@@ -164,8 +203,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
164
203
  if (typeof filter === "string") {
165
204
  const sql = filter ? `DELETE FROM "${table}" WHERE ${filter}` : `DELETE FROM "${table}"`;
166
205
  try {
167
- const result = this.db.prepare(sql).run(...(params ?? []));
168
- return { success: true, rowsAffected: result.changes };
206
+ const result = this.db.prepare(sql).run(...toSqlParams(params ?? []));
207
+ return { success: true, rowsAffected: Number(result.changes) };
169
208
  } catch (e) {
170
209
  return { success: false, rowsAffected: 0, error: (e as Error).message };
171
210
  }
@@ -176,8 +215,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
176
215
  const values = Object.values(filter);
177
216
 
178
217
  try {
179
- const result = this.db.prepare(sql).run(...values);
180
- return { success: true, rowsAffected: result.changes };
218
+ const result = this.db.prepare(sql).run(...toSqlParams(values));
219
+ return { success: true, rowsAffected: Number(result.changes) };
181
220
  } catch (e) {
182
221
  return { success: false, rowsAffected: 0, error: (e as Error).message };
183
222
  }
@@ -233,6 +272,66 @@ export class SQLiteAdapter implements DatabaseAdapter {
233
272
  lastInsertId(): number | bigint | null { return this._lastInsertId; }
234
273
  close(): void { this.db.close(); }
235
274
 
275
+ /**
276
+ * Atomically increment and return the next value of a tina4_sequences row.
277
+ *
278
+ * DB-contract B (no duplicate primary keys under concurrency): the old
279
+ * read-increment-read path in Database.sequenceNext() yields at every `await`
280
+ * between the read and the write, so two concurrent async callers can read the
281
+ * same `current_value` and return the same id. This method runs the WHOLE
282
+ * operation — ensure-table, seed-if-absent, and the increment-and-return — as
283
+ * ONE synchronous burst on the single shared `node:sqlite` connection. Because
284
+ * `node:sqlite` is synchronous and JavaScript is single-threaded, no other
285
+ * async task can interleave between the statements (there is no `await`
286
+ * inside), so the increment is atomic and every caller gets a distinct id.
287
+ * This is the Node analog of the Python master holding SQLiteAdapter._write_lock
288
+ * across the whole op.
289
+ *
290
+ * On SQLite >= 3.35 a single `UPDATE ... SET current_value = current_value + 1
291
+ * ... RETURNING current_value` is itself atomic and returns the new value in
292
+ * one statement (read via prepare().all() — stmt.run() does not surface
293
+ * RETURNING rows). Older SQLite does `UPDATE ... + 1` then `SELECT`, still
294
+ * race-safe because both run in the same synchronous burst.
295
+ *
296
+ * @throws if the sequence row vanishes mid-increment (never silently returns 1).
297
+ */
298
+ sequenceNextSqlite(seqName: string, seedValue: number): number {
299
+ // Ensure the sequence table exists (idempotent).
300
+ this.db.exec(
301
+ "CREATE TABLE IF NOT EXISTS tina4_sequences (" +
302
+ "seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " +
303
+ "current_value INTEGER NOT NULL DEFAULT 0)",
304
+ );
305
+ // Race-safe seed: INSERT OR IGNORE is a no-op if the row already exists, so
306
+ // there is never a read-then-insert gap.
307
+ this.db.prepare(
308
+ "INSERT OR IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)",
309
+ ).run(seqName, seedValue);
310
+
311
+ const supportsReturning = sqliteVersionAtLeast(3, 35, 0);
312
+ let row: { current_value?: number | bigint } | undefined;
313
+ if (supportsReturning) {
314
+ // One atomic increment-and-return.
315
+ row = this.db.prepare(
316
+ "UPDATE tina4_sequences SET current_value = current_value + 1 " +
317
+ "WHERE seq_name = ? RETURNING current_value",
318
+ ).get(seqName) as { current_value?: number | bigint } | undefined;
319
+ } else {
320
+ // Older SQLite (< 3.35, no RETURNING): increment then read. Still
321
+ // race-safe because both run in the same synchronous burst (no await).
322
+ this.db.prepare(
323
+ "UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?",
324
+ ).run(seqName);
325
+ row = this.db.prepare(
326
+ "SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
327
+ ).get(seqName) as { current_value?: number | bigint } | undefined;
328
+ }
329
+ if (!row || row.current_value == null) {
330
+ throw new Error(`getNextId: sequence row '${seqName}' vanished mid-increment`);
331
+ }
332
+ return Number(row.current_value);
333
+ }
334
+
236
335
  tableExists(name: string): boolean {
237
336
  // v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
238
337
  // Query that database's own sqlite_master when the prefix is a plain
@@ -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
  */
@@ -1086,7 +1225,10 @@ export class BaseModel {
1086
1225
 
1087
1226
  const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
1088
1227
  const relKey = relatedClass.tableName.toLowerCase();
1089
- this[relKey] = related;
1228
+ // Write through the BaseModel index signature: a generic `T` cannot be
1229
+ // indexed for writing (TS2862), but `T extends BaseModel` guarantees `this`
1230
+ // is a BaseModel, whose `[key: string]: unknown` signature is writable.
1231
+ (this as BaseModel)[relKey] = related;
1090
1232
  return related;
1091
1233
  }
1092
1234
 
@@ -1118,7 +1260,8 @@ export class BaseModel {
1118
1260
  const rows = await adapterQuery(db, sql, [pkValue]);
1119
1261
  const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
1120
1262
  const relKey = relatedClass.tableName.toLowerCase();
1121
- this[relKey] = related;
1263
+ // See hasOne: write through BaseModel's writable index signature (TS2862).
1264
+ (this as BaseModel)[relKey] = related;
1122
1265
  return related;
1123
1266
  }
1124
1267
 
@@ -1153,7 +1296,8 @@ export class BaseModel {
1153
1296
 
1154
1297
  const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
1155
1298
  const relKey = relatedClass.tableName.toLowerCase();
1156
- this[relKey] = related;
1299
+ // See hasOne: write through BaseModel's writable index signature (TS2862).
1300
+ (this as BaseModel)[relKey] = related;
1157
1301
  return related;
1158
1302
  }
1159
1303
 
@@ -1244,9 +1388,15 @@ export class BaseModel {
1244
1388
  const base = r.model.toLowerCase();
1245
1389
  const related = BaseModel._modelRegistry[r.model];
1246
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();
1247
1396
  return (
1248
1397
  base === want ||
1249
1398
  base + "s" === want ||
1399
+ (rel !== undefined && rel === want) ||
1250
1400
  (table !== undefined && table === want)
1251
1401
  );
1252
1402
  };
@@ -7,11 +7,14 @@
7
7
  *
8
8
  * One store, two layers (mirrors the Python master — tina4_python/database/connection.py):
9
9
  *
10
- * • request-scoped (DEFAULT ON, off-switch TINA4_AUTO_CACHING=false) — dedupes
10
+ * • request-scoped (DEFAULT OFF, opt-in TINA4_AUTO_CACHING=true) — dedupes
11
11
  * identical SELECTs to protect the DB from rapid repeat reads. Cleared at the
12
12
  * START of every HTTP request (via Database.resetRequestCaches()) AND on any
13
13
  * write, with a short safety TTL (TINA4_AUTO_CACHING_TTL, default 5s) for
14
- * non-request contexts (scripts/workers).
14
+ * non-request contexts (scripts/workers). Default OFF because a request-scoped
15
+ * cache defaulting ON is a footgun — a read-after-write in one request (e.g.
16
+ * SELECT MAX(id) then INSERT) returns a cached pre-write value. Opt in for
17
+ * read-heavy endpoints.
15
18
  * • persistent (opt-in, TINA4_DB_CACHE=true) — cross-request TTL cache that is
16
19
  * NOT cleared per request; entries expire by TINA4_DB_CACHE_TTL (default 30s).
17
20
  *
@@ -45,7 +48,7 @@ function isTruthy(val: string | undefined): boolean {
45
48
  export interface CachedAdapterOptions {
46
49
  /** Force-enable the persistent (cross-request) layer. Defaults to TINA4_DB_CACHE. */
47
50
  persistent?: boolean;
48
- /** Force-enable the request-scoped layer. Defaults to TINA4_AUTO_CACHING (default true). */
51
+ /** Force-enable the request-scoped layer. Defaults to TINA4_AUTO_CACHING (default false / opt-in). */
49
52
  requestScoped?: boolean;
50
53
  /** Override the effective TTL (seconds). Defaults to the mode-appropriate env var. */
51
54
  ttl?: number;
@@ -65,7 +68,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
65
68
  private cache: QueryCache;
66
69
  /** Persistent (cross-request) layer — TINA4_DB_CACHE. */
67
70
  private cachePersistent: boolean;
68
- /** Request-scoped layer — TINA4_AUTO_CACHING (default ON). */
71
+ /** Request-scoped layer — TINA4_AUTO_CACHING (default OFF / opt-in). */
69
72
  private cacheRequestScoped: boolean;
70
73
  private enabled: boolean;
71
74
  private ttl: number;
@@ -94,8 +97,14 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
94
97
  constructor(adapter: DatabaseAdapter, options: CachedAdapterOptions = {}) {
95
98
  this.adapter = adapter;
96
99
  this.cachePersistent = options.persistent ?? isTruthy(process.env.TINA4_DB_CACHE);
97
- this.cacheRequestScoped = options.requestScoped
98
- ?? (process.env.TINA4_AUTO_CACHING === undefined ? true : isTruthy(process.env.TINA4_AUTO_CACHING));
100
+ // Request-scoped cache defaults to OFF (opt-in). A request-scoped cache
101
+ // defaulting ON is a footgun: a `SELECT MAX(id)` (or generator read) right
102
+ // before an INSERT in the same request returns a cached pre-write value →
103
+ // duplicate primary keys; any read-after-write in one request shows stale
104
+ // state. So the DEFAULT is OFF; opt in for read-heavy endpoints with
105
+ // TINA4_AUTO_CACHING=true. Mirrors the Python master
106
+ // (tina4_python/database/connection.py: default literal "false").
107
+ this.cacheRequestScoped = options.requestScoped ?? isTruthy(process.env.TINA4_AUTO_CACHING);
99
108
  this.enabled = this.cachePersistent || this.cacheRequestScoped;
100
109
 
101
110
  if (options.ttl !== undefined) {