tina4-nodejs 3.13.36 → 3.13.38

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 (50) hide show
  1. package/CLAUDE.md +51 -19
  2. package/package.json +5 -3
  3. package/packages/cli/src/bin.ts +7 -0
  4. package/packages/cli/src/commands/init.ts +1 -0
  5. package/packages/cli/src/commands/metrics.ts +154 -0
  6. package/packages/cli/src/commands/routes.ts +3 -3
  7. package/packages/core/public/js/tina4-dev-admin.js +212 -212
  8. package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
  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 +75 -26
  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 +14 -8
  18. package/packages/core/src/logger.ts +1 -1
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/messenger.ts +111 -11
  21. package/packages/core/src/metrics.ts +232 -33
  22. package/packages/core/src/middleware.ts +129 -39
  23. package/packages/core/src/plan.ts +1 -1
  24. package/packages/core/src/queue.ts +1 -1
  25. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  26. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  27. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  28. package/packages/core/src/rateLimiter.ts +1 -1
  29. package/packages/core/src/response.ts +90 -6
  30. package/packages/core/src/router.ts +2 -2
  31. package/packages/core/src/server.ts +26 -4
  32. package/packages/core/src/session.ts +130 -18
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  34. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  35. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  36. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  37. package/packages/core/src/testClient.ts +1 -1
  38. package/packages/core/src/websocket.ts +247 -33
  39. package/packages/core/src/websocketBackplane.ts +210 -10
  40. package/packages/core/src/wsdl.ts +55 -21
  41. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  42. package/packages/orm/src/adapters/postgres.ts +26 -4
  43. package/packages/orm/src/adapters/sqlite.ts +112 -13
  44. package/packages/orm/src/baseModel.ts +8 -3
  45. package/packages/orm/src/cachedDatabase.ts +15 -6
  46. package/packages/orm/src/database.ts +257 -55
  47. package/packages/orm/src/index.ts +2 -1
  48. package/packages/orm/src/migration.ts +2 -2
  49. package/packages/orm/src/seeder.ts +443 -65
  50. package/packages/swagger/src/ui.ts +1 -1
@@ -19,6 +19,9 @@
19
19
  * }
20
20
  */
21
21
 
22
+ import { Log } from "./logger.js";
23
+ import { isDebugMode } from "./errorOverlay.js";
24
+
22
25
  // ── Types ────────────────────────────────────────────────────
23
26
 
24
27
  export interface WSDLOperationMeta {
@@ -261,13 +264,27 @@ export abstract class WSDLService {
261
264
  private convertValue(value: string, typeName: string): unknown {
262
265
  switch (typeName) {
263
266
  case "int":
264
- case "integer":
265
- return parseInt(value, 10);
267
+ case "integer": {
268
+ // Match Python's int(value) — a non-numeric value RAISES rather than
269
+ // silently yielding NaN. The thrown Error is caught in handle() and
270
+ // becomes a Server fault.
271
+ const n = parseInt(value, 10);
272
+ if (Number.isNaN(n)) {
273
+ throw new Error(`invalid integer value: ${JSON.stringify(value)}`);
274
+ }
275
+ return n;
276
+ }
266
277
  case "float":
267
278
  case "double":
268
279
  case "number":
269
- case "numeric":
270
- return parseFloat(value);
280
+ case "numeric": {
281
+ // Match Python's float(value) — non-numeric raises (→ Server fault).
282
+ const f = parseFloat(value);
283
+ if (Number.isNaN(f)) {
284
+ throw new Error(`invalid numeric value: ${JSON.stringify(value)}`);
285
+ }
286
+ return f;
287
+ }
271
288
  case "bool":
272
289
  case "boolean":
273
290
  return ["true", "1", "yes"].includes(value.toLowerCase());
@@ -389,6 +406,16 @@ export abstract class WSDLService {
389
406
  async handle(soapXml: string = ""): Promise<string> {
390
407
  const ops = this.discoverOperations();
391
408
 
409
+ // SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message.
410
+ // Rejecting any DOCTYPE/DTD up front — BEFORE the body is parsed — also
411
+ // closes the XML entity-expansion (billion-laughs) and external-entity
412
+ // (XXE) attack surface regardless of parser internals. The operation
413
+ // never runs. (Node's parser is hand-rolled and already immune; this is
414
+ // defence in depth + consistent fault behaviour across all 4 frameworks.)
415
+ if (/<!DOCTYPE/i.test(soapXml)) {
416
+ return this.soapFault("Client", "DOCTYPE declarations are not allowed in SOAP messages");
417
+ }
418
+
392
419
  // Parse SOAP body
393
420
  const body = extractSoapBody(soapXml);
394
421
  if (!body) {
@@ -413,33 +440,40 @@ export abstract class WSDLService {
413
440
  return this.soapFault("Client", `Operation not implemented: ${opName}`);
414
441
  }
415
442
 
416
- // Extract parameters from the operation element
417
- const children = extractChildren(operation.content);
418
- const params: unknown[] = [];
419
-
420
- if (opMeta.input) {
421
- for (const [paramName, paramType] of Object.entries(opMeta.input)) {
422
- const child = children.find((c) => c.name === paramName);
423
- if (child) {
424
- params.push(this.convertValue(child.value, paramType));
425
- } else {
426
- params.push(null);
427
- }
428
- }
429
- }
430
-
431
443
  // Lifecycle hook: before invocation
432
444
  this.onRequest(soapXml);
433
445
 
434
- // Invoke the method
446
+ // Invoke the method. Parameter conversion runs INSIDE the try so a
447
+ // non-numeric value for an int/float param (convertValue throws, matching
448
+ // Python's int()/float() raise) becomes a Server fault — not a silent NaN.
435
449
  try {
450
+ // Extract parameters from the operation element
451
+ const children = extractChildren(operation.content);
452
+ const params: unknown[] = [];
453
+
454
+ if (opMeta.input) {
455
+ for (const [paramName, paramType] of Object.entries(opMeta.input)) {
456
+ const child = children.find((c) => c.name === paramName);
457
+ if (child) {
458
+ params.push(this.convertValue(child.value, paramType));
459
+ } else {
460
+ params.push(null);
461
+ }
462
+ }
463
+ }
464
+
436
465
  const rawResult = await (method as (...args: unknown[]) => Promise<unknown>).call(this, ...params);
437
466
  // Lifecycle hook: after invocation — allow result transformation
438
467
  const result = this.onResult(rawResult as Record<string, unknown>);
439
468
  return this.soapResponse(opName, result);
440
469
  } catch (err) {
470
+ // Log the real cause, but only leak the detail to the client in debug
471
+ // mode — a resolver exception can carry internal state (DB credentials,
472
+ // file paths) that must not reach a SOAP client.
441
473
  const errMsg = err instanceof Error ? err.message : String(err);
442
- return this.soapFault("Server", errMsg);
474
+ Log.error(`WSDL operation '${opName}' failed: ${errMsg}`);
475
+ const detail = isDebugMode() ? errMsg : "Internal server error";
476
+ return this.soapFault("Server", detail);
443
477
  }
444
478
  }
445
479
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Minimal ambient type surface for the optional `pg` (node-postgres) peer
3
+ * dependency. `@types/pg` is intentionally NOT a dependency (pg is an optional
4
+ * peer loaded lazily via createRequire), so without this declaration the
5
+ * `typeof import("pg")` references in postgres.ts resolve to an *implicit* any
6
+ * (TS7016) and collapse `this.client` to `never`.
7
+ *
8
+ * This declares only the subset of the pg API that the PostgresAdapter uses:
9
+ * the `Client` class (connect/query/end) and the global `types.setTypeParser`
10
+ * registry. It carries no runtime weight — purely a compile-time contract that
11
+ * matches node-postgres' real shape.
12
+ */
13
+ declare module "pg" {
14
+ export interface QueryResultRow {
15
+ [column: string]: unknown;
16
+ }
17
+
18
+ export interface QueryResult<R extends QueryResultRow = QueryResultRow> {
19
+ rows: R[];
20
+ rowCount: number | null;
21
+ command: string;
22
+ oid: number;
23
+ fields: unknown[];
24
+ }
25
+
26
+ export interface ClientConfig {
27
+ host?: string;
28
+ port?: number;
29
+ user?: string;
30
+ password?: string;
31
+ database?: string;
32
+ connectionString?: string;
33
+ }
34
+
35
+ export class Client {
36
+ constructor(config?: ClientConfig | string);
37
+ connect(): Promise<void>;
38
+ query<R extends QueryResultRow = QueryResultRow>(
39
+ queryText: string,
40
+ values?: unknown[],
41
+ ): Promise<QueryResult<R>>;
42
+ end(): Promise<void>;
43
+ }
44
+
45
+ export class Pool {
46
+ constructor(config?: ClientConfig | string);
47
+ connect(): Promise<Client>;
48
+ query<R extends QueryResultRow = QueryResultRow>(
49
+ queryText: string,
50
+ values?: unknown[],
51
+ ): Promise<QueryResult<R>>;
52
+ end(): Promise<void>;
53
+ }
54
+
55
+ export interface TypeParsers {
56
+ setTypeParser(oid: number, parseFn: (value: string) => unknown): void;
57
+ }
58
+
59
+ export const types: TypeParsers;
60
+ }
@@ -86,7 +86,13 @@ export class PostgresAdapter implements DatabaseAdapter {
86
86
  await this.client!.connect();
87
87
  }
88
88
 
89
- private ensureConnected(): asserts this is { client: NonNullable<PostgresAdapter["client"]> } {
89
+ // NOTE: a plain runtime guard, not an `asserts this is ...` predicate. A
90
+ // type-narrowing predicate that re-declares the private `client` member
91
+ // intersects the class with a structural literal and collapses `this` to
92
+ // `never` (TS treats the private brand as unsatisfiable). The non-null
93
+ // `this.client!` assertions at the (already-guarded) call sites carry the
94
+ // narrowing instead, so behaviour is unchanged.
95
+ private ensureConnected(): void {
90
96
  if (!this.client) {
91
97
  throw new Error("PostgreSQL adapter not connected. Call connect() first.");
92
98
  }
@@ -107,6 +113,22 @@ export class PostgresAdapter implements DatabaseAdapter {
107
113
  });
108
114
  }
109
115
 
116
+ /**
117
+ * 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.
123
+ */
124
+ private normalizeId(value: unknown): number | bigint | null {
125
+ if (typeof value === "number" || typeof value === "bigint") return value;
126
+ if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
127
+ return Number(value);
128
+ }
129
+ return null;
130
+ }
131
+
110
132
  execute(sql: string, params?: unknown[]): unknown {
111
133
  this.ensureConnected();
112
134
  const convertedSql = this.convertPlaceholders(sql);
@@ -139,7 +161,7 @@ export class PostgresAdapter implements DatabaseAdapter {
139
161
  const convertedSql = this.convertPlaceholders(sql);
140
162
  const result = await this.client!.query(convertedSql, params);
141
163
  if (result.rows?.[0]?.id !== undefined) {
142
- this._lastInsertId = result.rows[0].id;
164
+ this._lastInsertId = this.normalizeId(result.rows[0].id);
143
165
  }
144
166
  return result;
145
167
  }
@@ -194,12 +216,12 @@ export class PostgresAdapter implements DatabaseAdapter {
194
216
  try {
195
217
  const result = await this.client!.query(sql, values);
196
218
  const insertedRow = result.rows[0];
197
- const id = insertedRow?.id ?? null;
219
+ const id = this.normalizeId(insertedRow?.id);
198
220
  if (id !== null) this._lastInsertId = id;
199
221
  return {
200
222
  success: true,
201
223
  rowsAffected: result.rowCount ?? 1,
202
- lastInsertId: id,
224
+ lastInsertId: id ?? undefined,
203
225
  };
204
226
  } catch (e) {
205
227
  return { success: false, rowsAffected: 0, error: (e as Error).message };
@@ -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
@@ -1086,7 +1086,10 @@ export class BaseModel {
1086
1086
 
1087
1087
  const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
1088
1088
  const relKey = relatedClass.tableName.toLowerCase();
1089
- this[relKey] = related;
1089
+ // Write through the BaseModel index signature: a generic `T` cannot be
1090
+ // indexed for writing (TS2862), but `T extends BaseModel` guarantees `this`
1091
+ // is a BaseModel, whose `[key: string]: unknown` signature is writable.
1092
+ (this as BaseModel)[relKey] = related;
1090
1093
  return related;
1091
1094
  }
1092
1095
 
@@ -1118,7 +1121,8 @@ export class BaseModel {
1118
1121
  const rows = await adapterQuery(db, sql, [pkValue]);
1119
1122
  const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
1120
1123
  const relKey = relatedClass.tableName.toLowerCase();
1121
- this[relKey] = related;
1124
+ // See hasOne: write through BaseModel's writable index signature (TS2862).
1125
+ (this as BaseModel)[relKey] = related;
1122
1126
  return related;
1123
1127
  }
1124
1128
 
@@ -1153,7 +1157,8 @@ export class BaseModel {
1153
1157
 
1154
1158
  const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
1155
1159
  const relKey = relatedClass.tableName.toLowerCase();
1156
- this[relKey] = related;
1160
+ // See hasOne: write through BaseModel's writable index signature (TS2862).
1161
+ (this as BaseModel)[relKey] = related;
1157
1162
  return related;
1158
1163
  }
1159
1164
 
@@ -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) {