tina4-nodejs 3.13.97 → 3.13.99

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 (96) hide show
  1. package/CLAUDE.md +60 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20620 -18995
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20459 -18815
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +28 -12
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +31 -13
  37. package/packages/frond/src/engine.ts +39 -7
  38. package/packages/orm/dist/index.js +10879 -9258
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/api.d.ts +11 -4
  63. package/types/core/src/background.d.ts +5 -2
  64. package/types/core/src/devAdmin.d.ts +35 -0
  65. package/types/core/src/dispatchPipeline.d.ts +41 -1
  66. package/types/core/src/errorOverlay.d.ts +13 -13
  67. package/types/core/src/index.d.ts +9 -6
  68. package/types/core/src/logger.d.ts +111 -185
  69. package/types/core/src/middleware.d.ts +40 -5
  70. package/types/core/src/portTakeover.d.ts +50 -0
  71. package/types/core/src/request.d.ts +15 -0
  72. package/types/core/src/response.d.ts +29 -0
  73. package/types/core/src/server.d.ts +92 -0
  74. package/types/core/src/testClient.d.ts +29 -3
  75. package/types/core/src/testing.d.ts +16 -12
  76. package/types/core/src/types.d.ts +21 -9
  77. package/types/core/src/version.d.ts +11 -0
  78. package/types/core/src/websocketBackplane.d.ts +1 -1
  79. package/types/frond/src/engine.d.ts +10 -0
  80. package/types/orm/src/adapters/firebird.d.ts +61 -2
  81. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  82. package/types/orm/src/adapters/mssql.d.ts +11 -0
  83. package/types/orm/src/adapters/mysql.d.ts +11 -0
  84. package/types/orm/src/adapters/odbc.d.ts +35 -4
  85. package/types/orm/src/adapters/postgres.d.ts +11 -0
  86. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  87. package/types/orm/src/baseModel.d.ts +45 -25
  88. package/types/orm/src/cachedDatabase.d.ts +27 -1
  89. package/types/orm/src/database.d.ts +56 -6
  90. package/types/orm/src/index.d.ts +3 -2
  91. package/types/orm/src/migration.d.ts +23 -5
  92. package/types/orm/src/query.d.ts +3 -0
  93. package/types/orm/src/seeder.d.ts +15 -2
  94. package/types/orm/src/sqlTranslator.d.ts +17 -4
  95. package/types/orm/src/types.d.ts +75 -16
  96. package/packages/core/src/errorOverlay.test.ts +0 -122
@@ -77,6 +77,32 @@ function _pluralRelKeys(): boolean {
77
77
  */
78
78
  const _fkRegistry = new Map<string, Array<{ foreignKey: string; declaringModel: string; hasManyKey: string }>>();
79
79
 
80
+ /**
81
+ * REL-EAGER-UNBOUNDED: max parent PKs per eager `WHERE fk IN (...)` query, so a
82
+ * very large parent set never yields an unbounded IN list (a query-size / driver
83
+ * parameter-limit risk). Each chunk is one query.
84
+ */
85
+ const EAGER_IN_CHUNK = 500;
86
+
87
+ /** Split an array into fixed-size chunks. */
88
+ function _chunk<T>(items: T[], size: number): T[][] {
89
+ const out: T[][] = [];
90
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Normalize an eager-load join key so an INTEGER primary key (1) matches a
96
+ * foreign key that round-tripped through the driver as "1.0" / 1.0 (SQLite gives
97
+ * a FK column TEXT/REAL affinity, so `String(1)` and `String("1.0")` would not
98
+ * group together). A genuinely non-numeric key (e.g. a UUID) is left untouched.
99
+ */
100
+ function _joinKey(v: unknown): string {
101
+ if (typeof v === "number") return String(v);
102
+ if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return String(Number(v));
103
+ return String(v);
104
+ }
105
+
80
106
  /**
81
107
  * BaseModel provides instance methods for ORM models.
82
108
  * Models extend this class and define static properties.
@@ -94,6 +120,39 @@ const _fkRegistry = new Map<string, Array<{ foreignKey: string; declaringModel:
94
120
  * static autoMap = true; // auto-generate fieldMapping from camelCase → snake_case
95
121
  * }
96
122
  */
123
+
124
+ /**
125
+ * The ONE process-wide, tag-aware model query cache shared by EVERY model, so a
126
+ * write on one model busts a cross-table query cached on another (CACHE-DEC-01).
127
+ * Mirrors the Python master's module-level `_query_cache`; it is the existing
128
+ * QueryCache subsystem (TTL + tags) -- zero new deps -- and is separate from the
129
+ * adapter-level auto-cache (CachedDatabaseAdapter), which is env-gated and off by
130
+ * default.
131
+ */
132
+ const modelQueryCache = new QueryCache({ defaultTtl: 0, maxSize: 500 });
133
+
134
+ /** Identifier after a FROM / JOIN keyword (optionally schema-qualified/quoted). */
135
+ const CACHE_TABLE_RE =
136
+ /\b(?:FROM|JOIN)\s+([`"[]?[A-Za-z_][\w$]*[`"\]]?(?:\.[`"[]?[A-Za-z_][\w$]*[`"\]]?)?)/gi;
137
+
138
+ /**
139
+ * Table names a query reads FROM / JOINs -- lowercased, schema-stripped.
140
+ *
141
+ * Best-effort: for each FROM/JOIN keyword it takes the following identifier,
142
+ * drops any quoting (backticks, double quotes, square brackets) and schema
143
+ * prefix (public.users -> users), and ignores the alias. A cached query is
144
+ * tagged with these tables so a write to any one of them invalidates it.
145
+ */
146
+ function tablesInSql(sql: string): string[] {
147
+ const tables = new Set<string>();
148
+ for (const match of (sql ?? "").matchAll(CACHE_TABLE_RE)) {
149
+ let name = match[1].replace(/[`"[\]]/g, "");
150
+ if (name.includes(".")) name = name.split(".").pop() ?? name;
151
+ if (name) tables.add(name.toLowerCase());
152
+ }
153
+ return [...tables];
154
+ }
155
+
97
156
  export class BaseModel {
98
157
  static tableName: string;
99
158
  static fields: Record<string, FieldDefinition>;
@@ -103,7 +162,6 @@ export class BaseModel {
103
162
  static hasMany?: RelationshipDefinition[];
104
163
  static belongsTo?: RelationshipDefinition[];
105
164
  static _db?: string;
106
- static _queryCache?: QueryCache;
107
165
 
108
166
  /**
109
167
  * When true, auto-generates fieldMapping entries from camelCase field names
@@ -512,7 +570,6 @@ export class BaseModel {
512
570
  const data = (rows as any)?.data ?? rows;
513
571
  const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
514
572
  const inst = new this(row) as T;
515
- (inst as any)._exists = true;
516
573
  return inst;
517
574
  });
518
575
 
@@ -564,7 +621,6 @@ export class BaseModel {
564
621
  for (const [key, value] of Object.entries(data)) {
565
622
  (this as any)[key] = value;
566
623
  }
567
- (this as any)._exists = true;
568
624
  return true;
569
625
  }
570
626
 
@@ -696,18 +752,6 @@ export class BaseModel {
696
752
  async save(): Promise<this | false> {
697
753
  const ModelClass = this.constructor as typeof BaseModel;
698
754
 
699
- // ── Canonical #2: validate() is enforced. An invalid model never reaches
700
- // the driver — fail loud (log + lastError), return false. ──
701
- const errors = this.validate();
702
- if (errors.length > 0) {
703
- this.lastError = errors.join("; ");
704
- Log.error(
705
- `${ModelClass.name}.save() refused: validation failed for table ` +
706
- `'${ModelClass.tableName}' — ${this.lastError}`,
707
- );
708
- return false;
709
- }
710
-
711
755
  const db = ModelClass.getDb();
712
756
  const pk = ModelClass.getPkField();
713
757
  const pkCol = ModelClass.getPkColumn();
@@ -755,6 +799,21 @@ export class BaseModel {
755
799
  }
756
800
  }
757
801
 
802
+ // ── Canonical #2: validate() is enforced. An invalid model never reaches
803
+ // the driver — fail loud (log + lastError), return false. Feature 19: on an
804
+ // UPDATE the partial-update mode (isUpdate) is passed so an unset field is
805
+ // not spuriously "required" (the persisted row already carries it), while a
806
+ // field that IS present stays held to its type/length/pattern/range rules.
807
+ const errors = this.validate(isUpdate);
808
+ if (errors.length > 0) {
809
+ this.lastError = errors.join("; ");
810
+ Log.error(
811
+ `${ModelClass.name}.save() refused: validation failed for table ` +
812
+ `'${ModelClass.tableName}' — ${this.lastError}`,
813
+ );
814
+ return false;
815
+ }
816
+
758
817
  await adapterStartTransaction(db);
759
818
  try {
760
819
  if (isUpdate) {
@@ -895,7 +954,8 @@ export class BaseModel {
895
954
  }
896
955
  // Success — clear any previously-recorded error.
897
956
  this.lastError = null;
898
- (this as any)._exists = true;
957
+ // Bust cached reads of any table this write touched (CACHE-DEC-01).
958
+ ModelClass.clearCache();
899
959
  return this;
900
960
  }
901
961
 
@@ -918,7 +978,6 @@ export class BaseModel {
918
978
  const ModelClass = this.constructor as typeof BaseModel;
919
979
  const db = ModelClass.getDb();
920
980
  const pk = ModelClass.getPkField();
921
- const pkCol = ModelClass.getPkColumn();
922
981
  const pkValue = this[pk];
923
982
 
924
983
  if (pkValue === undefined || pkValue === null) {
@@ -944,6 +1003,8 @@ export class BaseModel {
944
1003
  await adapterRollback(db);
945
1004
  throw e;
946
1005
  }
1006
+ // Bust cached reads of any table this write touched (CACHE-DEC-01).
1007
+ ModelClass.clearCache();
947
1008
  return true;
948
1009
  }
949
1010
 
@@ -987,7 +1048,22 @@ export class BaseModel {
987
1048
  // isn't cached is simply skipped here — async lazy-load on a sync
988
1049
  // serializer is not possible after the Option A async refactor.
989
1050
  const data = this._relCache[relName];
990
- if (data === undefined) continue;
1051
+ if (data === undefined) {
1052
+ // LOAD-NODE-SERIALIZE-OMIT (feature 26, 3.13.99): the omission used
1053
+ // to be completely silent — a developer who forgot `include` on the
1054
+ // finder that produced this instance got a serialized object
1055
+ // missing the relation with no signal at all. Warn (never throw —
1056
+ // serialization must keep working) naming the model, the relation,
1057
+ // and the fix, so the gap is visible instead of a quiet data loss.
1058
+ Log.warning(
1059
+ `${ModelClass.name}.toDict(): relation "${relName}" was requested via ` +
1060
+ `include but was never eager-loaded (a synchronous serializer cannot ` +
1061
+ `lazy-load it), so it is OMITTED from the result. Pass ` +
1062
+ `include: ["${relName}"] to the finder (find/all/where/select/load) ` +
1063
+ `that produced this instance.`,
1064
+ );
1065
+ continue;
1066
+ }
991
1067
  if (data === null || data === undefined) {
992
1068
  result[relName] = null;
993
1069
  } else if (Array.isArray(data)) {
@@ -1064,13 +1140,16 @@ export class BaseModel {
1064
1140
  * Validate this instance's values against the model's field definitions.
1065
1141
  * Returns an array of error strings (empty array means valid).
1066
1142
  */
1067
- validate(): string[] {
1143
+ validate(isUpdate = false): string[] {
1068
1144
  const ModelClass = this.constructor as typeof BaseModel;
1069
1145
  const data: Record<string, unknown> = {};
1070
1146
  for (const name of Object.keys(ModelClass.fields)) {
1071
1147
  data[name] = this[name];
1072
1148
  }
1073
- const errors = validateFields(data, ModelClass.fields);
1149
+ // isUpdate wires the partial-update mode: on an update a field that is not
1150
+ // provided is not spuriously "required" (see validateFields). Field values
1151
+ // that ARE present stay held to their type/length/pattern/range rules.
1152
+ const errors = validateFields(data, ModelClass.fields, isUpdate);
1074
1153
  return errors.map((e) => `${e.field} ${e.message}`);
1075
1154
  }
1076
1155
 
@@ -1101,6 +1180,15 @@ export class BaseModel {
1101
1180
  mappedFields[dbCol] = def;
1102
1181
  }
1103
1182
  }
1183
+ // SOFTDEL-DEC-02: a softDelete model needs an is_deleted flag column, but
1184
+ // createTable() built the schema from DECLARED fields only — so a
1185
+ // softDelete = true model that never declared is_deleted produced a table
1186
+ // with NO such column, and every soft-delete read/write then errored on
1187
+ // the missing column. syncModels() adds it on boot; createTable() must too.
1188
+ // Inject it (INTEGER 0/1, default 0) unless the model already declares it.
1189
+ if (this.softDelete && !("is_deleted" in mappedFields)) {
1190
+ mappedFields["is_deleted"] = { type: "integer", default: 0 };
1191
+ }
1104
1192
  await adapterCreateTable(db, this.tableName, mappedFields);
1105
1193
  return true;
1106
1194
  }
@@ -1139,6 +1227,15 @@ export class BaseModel {
1139
1227
  colDefs.push(parts.join(" "));
1140
1228
  }
1141
1229
 
1230
+ // SOFTDEL-DEC-02 (fallback path): inject the is_deleted flag column for a
1231
+ // softDelete model that did not declare it, mirroring the adapter path above.
1232
+ if (this.softDelete) {
1233
+ const dbCols = Object.keys(this.fields).map((f) => this.getDbColumn(f));
1234
+ if (!dbCols.includes("is_deleted")) {
1235
+ colDefs.push(`"is_deleted" INTEGER DEFAULT 0`);
1236
+ }
1237
+ }
1238
+
1142
1239
  // A COMPOSITE key is declared ONCE, at table level; the per-column inline
1143
1240
  // form above is suppressed for it, because two inline primary keys is
1144
1241
  // invalid DDL on every engine.
@@ -1181,11 +1278,31 @@ export class BaseModel {
1181
1278
  }
1182
1279
 
1183
1280
  /**
1184
- * Run a raw SQL query with results cached by TTL. Cache is per-model-class.
1281
+ * Every table a cached query touches: this model's table plus every FROM/JOIN
1282
+ * table in `sql`. A write to any of these busts the entry (CACHE-DEC-01).
1283
+ */
1284
+ static _cacheTags(sql: string): string[] {
1285
+ const ModelClass = this as unknown as typeof BaseModel;
1286
+ const tags = [(ModelClass.tableName ?? "").toLowerCase()];
1287
+ for (const table of tablesInSql(sql)) {
1288
+ if (!tags.includes(table)) tags.push(table);
1289
+ }
1290
+ return tags;
1291
+ }
1292
+
1293
+ /**
1294
+ * Run a raw SQL query with results cached by TTL.
1295
+ *
1296
+ * Invalidation (CACHE-DEC-01): the entry is tagged by every table the query
1297
+ * touches (this model's table plus any FROM/JOIN tables) in ONE process-wide
1298
+ * shared cache, so a write through the ORM (save/delete/forceDelete/restore)
1299
+ * to ANY of those tables busts it -- including a cross-table JOIN cached on a
1300
+ * different model. `ttl <= 0` means NO-CACHE: the query runs and the rows are
1301
+ * returned but nothing is stored, so every read hits the database.
1185
1302
  *
1186
1303
  * @param sql SQL query string.
1187
1304
  * @param params Bind parameters.
1188
- * @param ttl Cache TTL in seconds (default 60).
1305
+ * @param ttl Cache TTL in seconds (default 60; <= 0 = no-cache).
1189
1306
  * @param limit Max records to return (default 100).
1190
1307
  * @param offset Records to skip (default 0).
1191
1308
  * @param include Relationship names to eager-load on cache miss.
@@ -1200,33 +1317,43 @@ export class BaseModel {
1200
1317
  include?: string[],
1201
1318
  ): Promise<T[]> {
1202
1319
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
1203
- if (!ModelClass._queryCache) {
1204
- ModelClass._queryCache = new QueryCache({ defaultTtl: ttl, maxSize: 500 });
1205
- }
1320
+ const db = ModelClass.getDb();
1321
+
1322
+ const runQuery = async (): Promise<T[]> => {
1323
+ const querySql = `${sql} LIMIT ${limit} OFFSET ${offset}`;
1324
+ const rows = await adapterQuery(db, querySql, params);
1325
+ const results = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
1326
+ if (include && results.length > 0) {
1327
+ await ModelClass._eagerLoad(results as BaseModel[], include);
1328
+ }
1329
+ return results;
1330
+ };
1331
+
1332
+ // ttl <= 0 is NO-CACHE: run it live, store nothing, read nothing.
1333
+ if (ttl <= 0) return runQuery();
1334
+
1206
1335
  const cacheKey = `${ModelClass.tableName}:${sql}:${limit}:${offset}`;
1207
- const key = QueryCache.queryKey(cacheKey, params ?? [], ModelClass.getDb().cacheIdentity ?? "");
1208
- const hit = ModelClass._queryCache.get(key) as T[] | undefined;
1336
+ const key = QueryCache.queryKey(cacheKey, params ?? [], (db as unknown as { cacheIdentity?: string }).cacheIdentity ?? "");
1337
+ const hit = modelQueryCache.get<T[]>(key);
1209
1338
  if (hit !== undefined) return hit;
1210
1339
 
1211
- const db = ModelClass.getDb();
1212
- const querySql = `${sql} LIMIT ${limit} OFFSET ${offset}`;
1213
- const rows = await adapterQuery(db, querySql, params);
1214
- const results = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
1215
- if (include && results.length > 0) {
1216
- await ModelClass._eagerLoad(results as BaseModel[], include);
1217
- }
1218
- ModelClass._queryCache.set(key, results, ttl);
1340
+ const results = await runQuery();
1341
+ modelQueryCache.set(key, results, ttl, ModelClass._cacheTags(sql));
1219
1342
  return results;
1220
1343
  }
1221
1344
 
1222
1345
  /**
1223
- * Clear the per-model query cache.
1346
+ * Invalidate every cached query that touches this model's table.
1347
+ *
1348
+ * Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads
1349
+ * this table is busted too (it carries this table's tag), while a query that
1350
+ * never touches this table is left intact. Called after every ORM write
1351
+ * (save/delete/forceDelete/restore) so a read-after-write never serves a
1352
+ * stale/deleted row (CACHE-DEC-01).
1224
1353
  */
1225
1354
  static clearCache(): void {
1226
1355
  const ModelClass = this as unknown as typeof BaseModel;
1227
- if (ModelClass._queryCache) {
1228
- ModelClass._queryCache.clear();
1229
- }
1356
+ modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
1230
1357
  }
1231
1358
 
1232
1359
  /**
@@ -1273,7 +1400,6 @@ export class BaseModel {
1273
1400
  const ModelClass = this.constructor as typeof BaseModel;
1274
1401
  const db = ModelClass.getDb();
1275
1402
  const pk = ModelClass.getPkField();
1276
- const pkCol = ModelClass.getPkColumn();
1277
1403
  const pkValue = this[pk];
1278
1404
 
1279
1405
  if (pkValue === undefined || pkValue === null) {
@@ -1291,6 +1417,8 @@ export class BaseModel {
1291
1417
  await adapterRollback(db);
1292
1418
  throw e;
1293
1419
  }
1420
+ // Bust cached reads of any table this write touched (CACHE-DEC-01).
1421
+ ModelClass.clearCache();
1294
1422
  return true;
1295
1423
  }
1296
1424
 
@@ -1305,7 +1433,6 @@ export class BaseModel {
1305
1433
 
1306
1434
  const db = ModelClass.getDb();
1307
1435
  const pk = ModelClass.getPkField();
1308
- const pkCol = ModelClass.getPkColumn();
1309
1436
  const pkValue = this[pk];
1310
1437
 
1311
1438
  if (pkValue === undefined || pkValue === null) {
@@ -1324,6 +1451,8 @@ export class BaseModel {
1324
1451
  throw e;
1325
1452
  }
1326
1453
  this.is_deleted = 0;
1454
+ // Bust cached reads of any table this write touched (CACHE-DEC-01).
1455
+ ModelClass.clearCache();
1327
1456
  return true;
1328
1457
  }
1329
1458
 
@@ -1441,17 +1570,27 @@ export class BaseModel {
1441
1570
  // indexed for writing (TS2862), but `T extends BaseModel` guarantees `this`
1442
1571
  // is a BaseModel, whose `[key: string]: unknown` signature is writable.
1443
1572
  (this as BaseModel)[relKey] = related;
1573
+ // IMPREL-NODE-ORPHAN: also store under the serializer's key so an
1574
+ // imperatively-loaded relation is included by toDict([relKey]) -- toDict
1575
+ // reads _relCache, which the imperative path never populated before, so an
1576
+ // imperatively-loaded relation was orphaned from serialization.
1577
+ (this as BaseModel)._relCache[relKey] = related;
1444
1578
  return related;
1445
1579
  }
1446
1580
 
1447
1581
  /**
1448
1582
  * Load has-many related model instances.
1583
+ *
1584
+ * With no explicit `limit` this returns the WHOLE set (paged internally, like
1585
+ * the lazy accessor), never a silent row cap -- so an imperatively-loaded
1586
+ * has_many yields the SAME row count as the lazy path. An explicit `limit`
1587
+ * still pages.
1449
1588
  */
1450
1589
  async hasMany<T extends BaseModel, R extends BaseModel>(
1451
1590
  this: T,
1452
1591
  relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
1453
1592
  foreignKey: string,
1454
- limit: number = 100,
1593
+ limit?: number,
1455
1594
  offset: number = 0,
1456
1595
  ): Promise<R[]> {
1457
1596
  const ModelClass = this.constructor as typeof BaseModel;
@@ -1463,17 +1602,28 @@ export class BaseModel {
1463
1602
  }
1464
1603
 
1465
1604
  const db = relatedClass.getDb();
1605
+ const orderCol = relatedClass.getPkColumn();
1466
1606
  let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${foreignKey}" = ?`;
1467
1607
  if (relatedClass.softDelete) {
1468
1608
  sql += ` AND is_deleted = 0`;
1469
1609
  }
1470
- sql += ` LIMIT ${limit} OFFSET ${offset}`;
1610
+ // Order by the child PK for a stable read (parity with the lazy accessor).
1611
+ sql += ` ORDER BY "${orderCol}"`;
1612
+ // IMPREL-PY-CAP parity: with no explicit limit, return the WHOLE set (like
1613
+ // the lazy accessor, which is uncapped) instead of a silent 100-row cap. An
1614
+ // explicit limit still pages (explicit, never silent).
1615
+ if (limit !== undefined) {
1616
+ sql += ` LIMIT ${limit} OFFSET ${offset}`;
1617
+ }
1471
1618
 
1472
1619
  const rows = await adapterQuery(db, sql, [pkValue]);
1473
1620
  const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
1474
1621
  const relKey = relatedClass.tableName.toLowerCase();
1475
1622
  // See hasOne: write through BaseModel's writable index signature (TS2862).
1476
1623
  (this as BaseModel)[relKey] = related;
1624
+ // IMPREL-NODE-ORPHAN: store under the serializer's key so an imperatively
1625
+ // loaded relation is included by toDict([relKey]) (was orphaned).
1626
+ (this as BaseModel)._relCache[relKey] = related;
1477
1627
  return related;
1478
1628
  }
1479
1629
 
@@ -1510,6 +1660,9 @@ export class BaseModel {
1510
1660
  const relKey = relatedClass.tableName.toLowerCase();
1511
1661
  // See hasOne: write through BaseModel's writable index signature (TS2862).
1512
1662
  (this as BaseModel)[relKey] = related;
1663
+ // IMPREL-NODE-ORPHAN: store under the serializer's key so an imperatively
1664
+ // loaded relation is included by toDict([relKey]) (was orphaned).
1665
+ (this as BaseModel)._relCache[relKey] = related;
1513
1666
  return related;
1514
1667
  }
1515
1668
 
@@ -1520,20 +1673,19 @@ export class BaseModel {
1520
1673
 
1521
1674
  static registerModel(name: string, modelClass: typeof BaseModel): void {
1522
1675
  BaseModel._modelRegistry[name] = modelClass;
1523
- // Eagerly process this model's foreignKey fields so the cross-model
1524
- // _fkRegistry is populated as soon as the model is known not only when
1525
- // the *declaring* model happens to be touched first. This is what makes
1526
- // eager loading work standalone (without server-boot auto-discovery):
1527
- // a parent's hasMany is registered by the child's _processForeignKeys(),
1528
- // so we must run it for every registered model proactively.
1529
- modelClass._processForeignKeys();
1676
+ // Wire every registered model's FK relationships AND lazy accessors now that
1677
+ // a new model is known. A parent's has-many is declared by the CHILD's
1678
+ // foreignKey field, so re-wiring all registered models on each registration
1679
+ // makes BOTH sides functional (belongsTo + has-many) with lazy accessors,
1680
+ // without depending on server-boot auto-discovery. Idempotent.
1681
+ BaseModel._processAllForeignKeys();
1530
1682
  }
1531
1683
 
1532
1684
  /**
1533
1685
  * Process foreignKey fields on every registered model so the cross-model
1534
1686
  * _fkRegistry (and each model's belongsTo/hasMany) is fully wired regardless
1535
- * of which model was used first. Idempotent _processForeignKeys() and
1536
- * _applyFkRegistry() both guard against duplicates.
1687
+ * of which model was used first, then attach the lazy relationship accessors.
1688
+ * Idempotent every step guards against duplicates.
1537
1689
  */
1538
1690
  private static _processAllForeignKeys(): void {
1539
1691
  for (const modelClass of Object.values(BaseModel._modelRegistry)) {
@@ -1542,6 +1694,99 @@ export class BaseModel {
1542
1694
  for (const modelClass of Object.values(BaseModel._modelRegistry)) {
1543
1695
  modelClass._applyFkRegistry();
1544
1696
  }
1697
+ // REL-NODE-AUTOWIRE-DEAD: attach lazy accessors (post.author / author.posts)
1698
+ // on both sides so DECLARATIVE relationships actually function — matching the
1699
+ // imperative belongsTo()/hasMany() path and Python/PHP/Ruby.
1700
+ for (const modelClass of Object.values(BaseModel._modelRegistry)) {
1701
+ modelClass._wireRelationshipAccessors();
1702
+ }
1703
+ }
1704
+
1705
+ /**
1706
+ * REL-NODE-AUTOWIRE-DEAD: attach a lazy-loading accessor for each declared
1707
+ * relationship (belongsTo/hasOne/hasMany) on this model's prototype, so
1708
+ * `post.author` / `author.posts` resolve on attribute access. The accessor is
1709
+ * async (Node lazy load) and caches into `_relCache` — the SAME cache eager
1710
+ * loading fills, so `toDict` stays consistent once a relation has been loaded.
1711
+ * Reuses the imperative belongsTo()/hasOne() path and the cross-model registry;
1712
+ * a soft-deleted child is excluded and the has-many read is uncapped.
1713
+ */
1714
+ static _wireRelationshipAccessors(): void {
1715
+ const proto = this.prototype as Record<string, unknown>;
1716
+ const fields = this.fields ?? {};
1717
+
1718
+ const define = (
1719
+ name: string,
1720
+ rel: RelationshipDefinition,
1721
+ kind: "belongsTo" | "hasOne" | "hasMany",
1722
+ ): void => {
1723
+ // Never shadow a declared column or an existing member (method / prior
1724
+ // accessor). `name in proto` also makes re-wiring idempotent.
1725
+ if (!name || name in fields || name in proto) return;
1726
+ Object.defineProperty(proto, name, {
1727
+ configurable: true,
1728
+ enumerable: false,
1729
+ get(this: Record<string, unknown>) {
1730
+ const cache = this._relCache as Record<string, unknown>;
1731
+ if (name in cache) return cache[name]; // eager or prior-lazy value
1732
+ const pending = (this._relPromises ??= {}) as Record<string, unknown>;
1733
+ if (name in pending) return pending[name]; // in-flight dedupe
1734
+ const promise = (async () => {
1735
+ const related = BaseModel._modelRegistry[rel.model];
1736
+ let value: unknown;
1737
+ if (!related) {
1738
+ value = kind === "hasMany" ? [] : null;
1739
+ } else if (kind === "belongsTo") {
1740
+ value = await (this as unknown as BaseModel).belongsTo(related as never, rel.foreignKey);
1741
+ } else if (kind === "hasOne") {
1742
+ value = await (this as unknown as BaseModel).hasOne(related as never, rel.foreignKey);
1743
+ } else {
1744
+ value = await BaseModel._loadHasManyLazy(this as unknown as BaseModel, related, rel.foreignKey);
1745
+ }
1746
+ cache[name] = value;
1747
+ delete pending[name];
1748
+ return value;
1749
+ })();
1750
+ pending[name] = promise;
1751
+ return promise;
1752
+ },
1753
+ });
1754
+ };
1755
+
1756
+ for (const rel of this.belongsTo ?? []) {
1757
+ const name = rel.relatedName
1758
+ ?? (rel.foreignKey.endsWith("_id") ? rel.foreignKey.slice(0, -3) : rel.foreignKey);
1759
+ define(name, rel, "belongsTo");
1760
+ }
1761
+ for (const rel of this.hasOne ?? []) {
1762
+ define(rel.relatedName ?? rel.model.toLowerCase(), rel, "hasOne");
1763
+ }
1764
+ for (const rel of this.hasMany ?? []) {
1765
+ define(rel.relatedName ?? (rel.model.toLowerCase() + "s"), rel, "hasMany");
1766
+ }
1767
+ }
1768
+
1769
+ /**
1770
+ * Lazy has-many read for a relationship accessor: excludes soft-deleted
1771
+ * children and returns the WHOLE set (adapterQuery is uncapped, so the tail is
1772
+ * never lost). Ordered by the child PK for a stable read.
1773
+ */
1774
+ private static async _loadHasManyLazy(
1775
+ inst: BaseModel,
1776
+ relatedClass: typeof BaseModel,
1777
+ foreignKey: string,
1778
+ ): Promise<BaseModel[]> {
1779
+ const ModelClass = inst.constructor as typeof BaseModel;
1780
+ const pk = ModelClass.getPkField();
1781
+ const pkValue = (inst as Record<string, unknown>)[pk];
1782
+ if (pkValue === undefined || pkValue === null) return [];
1783
+ const db = relatedClass.getDb();
1784
+ const orderCol = relatedClass.getPkColumn();
1785
+ let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${foreignKey}" = ?`;
1786
+ if (relatedClass.softDelete) sql += ` AND is_deleted = 0`;
1787
+ sql += ` ORDER BY "${orderCol}"`;
1788
+ const rows = await adapterQuery(db, sql, [pkValue]);
1789
+ return rows.map((row) => new (relatedClass as unknown as new (d: Record<string, unknown>) => BaseModel)(row as Record<string, unknown>));
1545
1790
  }
1546
1791
 
1547
1792
  /**
@@ -1629,7 +1874,7 @@ export class BaseModel {
1629
1874
  if (!relDef || !relType) {
1630
1875
  // Don't silently skip — a typo'd or unknown include name is almost
1631
1876
  // always a developer mistake. Surface it so it's visible.
1632
- Log.warn(
1877
+ Log.warning(
1633
1878
  `eager-load: include "${relName}" did not match any relationship on ` +
1634
1879
  `${ModelClass.name} (table "${ModelClass.tableName}"). ` +
1635
1880
  `Accepted forms are the related model name, its singular/plural ` +
@@ -1651,15 +1896,19 @@ export class BaseModel {
1651
1896
  .filter((v) => v !== undefined && v !== null);
1652
1897
  if (pkValues.length === 0) continue;
1653
1898
 
1654
- const placeholders = pkValues.map(() => "?").join(",");
1655
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${fk}" IN (${placeholders})`;
1656
- if (relatedClass.softDelete) {
1657
- sql += ` AND is_deleted = 0`;
1899
+ // REL-EAGER-UNBOUNDED: chunk the parent PKs so the IN list stays bounded
1900
+ // (each chunk is one query). adapterQuery is uncapped, so no row cap.
1901
+ const related: BaseModel[] = [];
1902
+ for (const pkChunk of _chunk(pkValues, EAGER_IN_CHUNK)) {
1903
+ const placeholders = pkChunk.map(() => "?").join(",");
1904
+ let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${fk}" IN (${placeholders})`;
1905
+ if (relatedClass.softDelete) {
1906
+ sql += ` AND is_deleted = 0`;
1907
+ }
1908
+ const rows = await adapterQuery(db, sql, pkChunk);
1909
+ for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
1658
1910
  }
1659
1911
 
1660
- const rows = await adapterQuery(db, sql, pkValues);
1661
- const related = rows.map((row) => new relatedClass(row as Record<string, unknown>));
1662
-
1663
1912
  // Eager load nested
1664
1913
  if (nested.length > 0 && related.length > 0) {
1665
1914
  await relatedClass._eagerLoad(related, nested);
@@ -1670,13 +1919,13 @@ export class BaseModel {
1670
1919
  const fkProp = relatedReverseMap[fk] ?? fk;
1671
1920
  const grouped: Record<string, BaseModel[]> = {};
1672
1921
  for (const record of related) {
1673
- const fkVal = String(record[fkProp]);
1922
+ const fkVal = _joinKey(record[fkProp]);
1674
1923
  if (!grouped[fkVal]) grouped[fkVal] = [];
1675
1924
  grouped[fkVal].push(record);
1676
1925
  }
1677
1926
 
1678
1927
  for (const inst of instances) {
1679
- const pkVal = String(inst[pk]);
1928
+ const pkVal = _joinKey(inst[pk]);
1680
1929
  const records = grouped[pkVal] || [];
1681
1930
  if (relType === "hasOne") {
1682
1931
  inst._relCache[relName] = records[0] ?? null;
@@ -1697,28 +1946,31 @@ export class BaseModel {
1697
1946
 
1698
1947
  const relatedPk = relatedClass.getPkField();
1699
1948
  const relatedPkCol = relatedClass.getPkColumn();
1700
- const placeholders = fkValues.map(() => "?").join(",");
1701
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${relatedPkCol}" IN (${placeholders})`;
1702
- if (relatedClass.softDelete) {
1703
- sql += ` AND is_deleted = 0`;
1949
+ // REL-EAGER-UNBOUNDED: chunk the FK values so the IN list stays bounded.
1950
+ const related: BaseModel[] = [];
1951
+ for (const fkChunk of _chunk(fkValues, EAGER_IN_CHUNK)) {
1952
+ const placeholders = fkChunk.map(() => "?").join(",");
1953
+ let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${relatedPkCol}" IN (${placeholders})`;
1954
+ if (relatedClass.softDelete) {
1955
+ sql += ` AND is_deleted = 0`;
1956
+ }
1957
+ const rows = await adapterQuery(db, sql, fkChunk);
1958
+ for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
1704
1959
  }
1705
1960
 
1706
- const rows = await adapterQuery(db, sql, fkValues);
1707
- const related = rows.map((row) => new relatedClass(row as Record<string, unknown>));
1708
-
1709
1961
  if (nested.length > 0 && related.length > 0) {
1710
1962
  await relatedClass._eagerLoad(related, nested);
1711
1963
  }
1712
1964
 
1713
1965
  const lookup: Record<string, BaseModel> = {};
1714
1966
  for (const record of related) {
1715
- lookup[String(record[relatedPk])] = record;
1967
+ lookup[_joinKey(record[relatedPk])] = record;
1716
1968
  }
1717
1969
 
1718
1970
  for (const inst of instances) {
1719
1971
  const fkVal = inst[fkProp];
1720
1972
  inst._relCache[relName] = fkVal !== undefined && fkVal !== null
1721
- ? lookup[String(fkVal)] ?? null
1973
+ ? lookup[_joinKey(fkVal)] ?? null
1722
1974
  : null;
1723
1975
  }
1724
1976
  }