tina4-nodejs 3.13.16 → 3.13.18

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.
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.16)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.18)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.16 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.18 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -1062,7 +1062,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1062
1062
  ## v3 Features Summary
1063
1063
 
1064
1064
  - **45 built-in features**, zero third-party dependencies
1065
- - **3,644 tests** passing across all modules
1065
+ - **3,653 tests** passing across all modules
1066
1066
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1067
1067
  - **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
1068
1068
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.16",
6
+ "version": "3.13.18",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -10,12 +10,14 @@ import { SQLTranslator } from "../sqlTranslation.js";
10
10
  import { createRequire } from "node:module";
11
11
 
12
12
  let pg: typeof import("pg") | null = null;
13
+ let typeParsersRegistered = false;
13
14
 
14
15
  function requirePg(): typeof import("pg") {
15
16
  if (pg) return pg;
16
17
  try {
17
18
  const req = createRequire(import.meta.url);
18
19
  pg = req("pg");
20
+ registerTypeParsers(pg!);
19
21
  return pg!;
20
22
  } catch {
21
23
  throw new Error(
@@ -28,6 +30,33 @@ function requirePg(): typeof import("pg") {
28
30
  }
29
31
  }
30
32
 
33
+ /**
34
+ * Register global pg type parsers so int8 and numeric/decimal columns decode to
35
+ * JS numbers instead of strings. node-postgres returns int8 (OID 20) and
36
+ * numeric (OID 1700) as strings by default to preserve full precision; Python,
37
+ * Ruby and PHP all return native numerics for aggregates (SUM, AVG, COUNT, …),
38
+ * so this brings Node to cross-framework parity. count() and getNextId() already
39
+ * coerce via Number(), so this is purely additive for them.
40
+ *
41
+ * PRECISION CAVEAT: values beyond Number.MAX_SAFE_INTEGER (2^53 - 1) lose
42
+ * precision when coerced to a JS double. That is the accepted trade-off for
43
+ * parity — Python and Ruby return native numerics (and lose precision the same
44
+ * way for floats) too. Applications that need exact 64-bit/arbitrary-precision
45
+ * values should select the column with an explicit ::text cast.
46
+ *
47
+ * Idempotent — registration runs once per process.
48
+ */
49
+ function registerTypeParsers(pgModule: typeof import("pg")): void {
50
+ if (typeParsersRegistered) return;
51
+ const types = pgModule.types ?? (pgModule as any).default?.types;
52
+ if (!types?.setTypeParser) return;
53
+ // OID 20 = int8 (bigint) → Number (NULL passes through untouched by pg)
54
+ types.setTypeParser(20, (v: string) => Number(v));
55
+ // OID 1700 = numeric / decimal → parseFloat
56
+ types.setTypeParser(1700, (v: string) => parseFloat(v));
57
+ typeParsersRegistered = true;
58
+ }
59
+
31
60
  export interface PostgresConfig {
32
61
  host?: string;
33
62
  port?: number;
@@ -8,6 +8,7 @@ import { validate as validateFields } from "./validation.js";
8
8
  import { QueryBuilder } from "./queryBuilder.js";
9
9
  import { SQLiteAdapter } from "./adapters/sqlite.js";
10
10
  import { QueryCache } from "./sqlTranslation.js";
11
+ import { Log } from "@tina4/core";
11
12
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
12
13
 
13
14
  /**
@@ -1149,6 +1150,28 @@ export class BaseModel {
1149
1150
 
1150
1151
  static registerModel(name: string, modelClass: typeof BaseModel): void {
1151
1152
  BaseModel._modelRegistry[name] = modelClass;
1153
+ // Eagerly process this model's foreignKey fields so the cross-model
1154
+ // _fkRegistry is populated as soon as the model is known — not only when
1155
+ // the *declaring* model happens to be touched first. This is what makes
1156
+ // eager loading work standalone (without server-boot auto-discovery):
1157
+ // a parent's hasMany is registered by the child's _processForeignKeys(),
1158
+ // so we must run it for every registered model proactively.
1159
+ modelClass._processForeignKeys();
1160
+ }
1161
+
1162
+ /**
1163
+ * Process foreignKey fields on every registered model so the cross-model
1164
+ * _fkRegistry (and each model's belongsTo/hasMany) is fully wired regardless
1165
+ * of which model was used first. Idempotent — _processForeignKeys() and
1166
+ * _applyFkRegistry() both guard against duplicates.
1167
+ */
1168
+ private static _processAllForeignKeys(): void {
1169
+ for (const modelClass of Object.values(BaseModel._modelRegistry)) {
1170
+ modelClass._processForeignKeys();
1171
+ }
1172
+ for (const modelClass of Object.values(BaseModel._modelRegistry)) {
1173
+ modelClass._applyFkRegistry();
1174
+ }
1152
1175
  }
1153
1176
 
1154
1177
  /**
@@ -1168,9 +1191,13 @@ export class BaseModel {
1168
1191
 
1169
1192
  const ModelClass = instances[0].constructor as typeof BaseModel;
1170
1193
 
1171
- // Apply FK registry so foreignKey fields auto-wire hasMany on referenced models
1172
- ModelClass._processForeignKeys();
1173
- ModelClass._applyFkRegistry();
1194
+ // Wire FK relationships across ALL registered models, not just the parent.
1195
+ // A parent's hasMany is declared by the CHILD's foreignKey field, so we must
1196
+ // process every registered model's FKs before resolving includes — otherwise
1197
+ // a standalone Author.findById(id, ["posts"]) silently finds nothing because
1198
+ // Post._processForeignKeys() never ran. _applyFkRegistry() then merges the
1199
+ // registered hasMany entries onto each model.
1200
+ BaseModel._processAllForeignKeys();
1174
1201
 
1175
1202
  // Group includes: top-level and nested
1176
1203
  const topLevel: Record<string, string[]> = {};
@@ -1186,28 +1213,54 @@ export class BaseModel {
1186
1213
  }
1187
1214
 
1188
1215
  for (const [relName, nested] of Object.entries(topLevel)) {
1189
- // Find the relationship definition
1216
+ // Find the relationship definition.
1217
+ //
1218
+ // Include names are resolved case-insensitively against each candidate
1219
+ // relation. Accepted forms (for a relation to model "Post" on table "posts"):
1220
+ // - the model name → "Post" / "post"
1221
+ // - the auto/related key → "post" (singular) or "posts" (when
1222
+ // TINA4_ORM_PLURAL_TABLE_NAMES is enabled)
1223
+ // - the table name → "posts"
1224
+ // All matching is lower-cased so "Post", "post" and "posts" all resolve.
1225
+ const want = relName.toLowerCase();
1190
1226
  let relDef: RelationshipDefinition | undefined;
1191
1227
  let relType: "hasOne" | "hasMany" | "belongsTo" | null = null;
1192
1228
 
1229
+ const matchesModel = (r: RelationshipDefinition): boolean => {
1230
+ const base = r.model.toLowerCase();
1231
+ const related = BaseModel._modelRegistry[r.model];
1232
+ const table = related?.tableName?.toLowerCase();
1233
+ return (
1234
+ base === want ||
1235
+ base + "s" === want ||
1236
+ (table !== undefined && table === want)
1237
+ );
1238
+ };
1239
+
1193
1240
  if (ModelClass.hasOne) {
1194
- relDef = ModelClass.hasOne.find((r) => r.model.toLowerCase() === relName || r.model === relName);
1241
+ relDef = ModelClass.hasOne.find(matchesModel);
1195
1242
  if (relDef) relType = "hasOne";
1196
1243
  }
1197
1244
  if (!relDef && ModelClass.hasMany) {
1198
- relDef = ModelClass.hasMany.find((r) => {
1199
- const base = r.model.toLowerCase();
1200
- const key = _pluralRelKeys() ? base + "s" : base;
1201
- return key === relName || base === relName || r.model === relName;
1202
- });
1245
+ relDef = ModelClass.hasMany.find(matchesModel);
1203
1246
  if (relDef) relType = "hasMany";
1204
1247
  }
1205
1248
  if (!relDef && ModelClass.belongsTo) {
1206
- relDef = ModelClass.belongsTo.find((r) => r.model.toLowerCase() === relName || r.model === relName);
1249
+ relDef = ModelClass.belongsTo.find(matchesModel);
1207
1250
  if (relDef) relType = "belongsTo";
1208
1251
  }
1209
1252
 
1210
- if (!relDef || !relType) continue;
1253
+ if (!relDef || !relType) {
1254
+ // Don't silently skip — a typo'd or unknown include name is almost
1255
+ // always a developer mistake. Surface it so it's visible.
1256
+ Log.warn(
1257
+ `eager-load: include "${relName}" did not match any relationship on ` +
1258
+ `${ModelClass.name} (table "${ModelClass.tableName}"). ` +
1259
+ `Accepted forms are the related model name, its singular/plural ` +
1260
+ `key, or the related table name (case-insensitive).`,
1261
+ );
1262
+ continue;
1263
+ }
1211
1264
 
1212
1265
  const relatedClass = BaseModel._modelRegistry[relDef.model];
1213
1266
  if (!relatedClass) continue;