tina4-nodejs 3.13.98 → 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.
- package/CLAUDE.md +24 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20620 -18995
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20459 -18815
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +28 -12
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +31 -13
- package/packages/frond/src/engine.ts +39 -7
- package/packages/orm/dist/index.js +10879 -9258
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +10 -0
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- 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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1204
|
-
|
|
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 ?? [],
|
|
1208
|
-
const hit =
|
|
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
|
|
1212
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
|
|
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
|
|
1536
|
-
*
|
|
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.
|
|
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
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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[
|
|
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[
|
|
1973
|
+
? lookup[_joinKey(fkVal)] ?? null
|
|
1722
1974
|
: null;
|
|
1723
1975
|
}
|
|
1724
1976
|
}
|