tina4-nodejs 3.13.133 → 3.13.134

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 +3 -3
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/packages/cli/dist/bin.js +3181 -3051
  5. package/packages/cli/src/commands/generate.ts +33 -22
  6. package/packages/cli/src/commands/lint.ts +77 -111
  7. package/packages/core/dist/index.js +3090 -2952
  8. package/packages/core/src/.tina4-metrics.json +15004 -0
  9. package/packages/core/src/aiClient.ts +199 -161
  10. package/packages/core/src/dispatchPipeline.ts +65 -67
  11. package/packages/core/src/docs.ts +52 -544
  12. package/packages/core/src/docsParser.ts +270 -0
  13. package/packages/core/src/docsScanner.ts +121 -0
  14. package/packages/core/src/docsSignatures.ts +165 -0
  15. package/packages/core/src/index.ts +2 -0
  16. package/packages/core/src/logger.ts +68 -82
  17. package/packages/core/src/mcp.ts +32 -60
  18. package/packages/core/src/messenger.ts +136 -157
  19. package/packages/core/src/middleware.ts +56 -60
  20. package/packages/core/src/plan.ts +78 -70
  21. package/packages/core/src/projectIndex.ts +15 -288
  22. package/packages/core/src/projectIndexExtractors.ts +126 -0
  23. package/packages/core/src/projectIndexStorage.ts +122 -0
  24. package/packages/core/src/push.ts +281 -0
  25. package/packages/core/src/server.ts +182 -183
  26. package/packages/frond/dist/index.js +607 -770
  27. package/packages/frond/src/engine.ts +670 -818
  28. package/packages/orm/dist/index.js +3100 -2965
  29. package/packages/orm/src/adapters/mongodb.ts +99 -144
  30. package/packages/orm/src/baseModel.ts +429 -515
  31. package/packages/orm/src/fakeData.ts +73 -61
  32. package/packages/orm/src/migration.ts +96 -126
  33. package/packages/orm/src/seeder.ts +6 -238
  34. package/packages/orm/src/seederTable.ts +101 -0
  35. package/packages/orm/src/seederTypes.ts +14 -0
  36. package/packages/orm/src/validation.ts +97 -80
  37. package/types/core/src/aiClient.d.ts +5 -0
  38. package/types/core/src/docsParser.d.ts +28 -0
  39. package/types/core/src/docsScanner.d.ts +1 -0
  40. package/types/core/src/docsSignatures.d.ts +11 -0
  41. package/types/core/src/index.d.ts +2 -0
  42. package/types/core/src/messenger.d.ts +8 -0
  43. package/types/core/src/projectIndexExtractors.d.ts +3 -0
  44. package/types/core/src/projectIndexStorage.d.ts +13 -0
  45. package/types/core/src/push.d.ts +45 -0
  46. package/types/frond/src/engine.d.ts +25 -0
  47. package/types/orm/src/fakeData.d.ts +3 -0
  48. package/types/orm/src/seeder.d.ts +3 -89
  49. package/types/orm/src/seederTable.d.ts +9 -0
  50. package/types/orm/src/seederTypes.d.ts +16 -0
@@ -783,204 +783,26 @@ export class BaseModel {
783
783
  const ModelClass = this.constructor as typeof BaseModel;
784
784
 
785
785
  const db = ModelClass.getDb();
786
- const pk = ModelClass.getPkField();
787
- const pkCol = ModelClass.getPkColumn();
786
+ const pk = primaryKeyFields(ModelClass)[0];
787
+ const pkCol = ModelClass.getDbColumn(pk);
788
788
  const pkValue = this[pk];
789
789
  const pkField = (ModelClass.fields as Record<string, FieldDefinition>)[pk];
790
790
  this._relCache = {}; // Clear relationship cache on save
791
791
 
792
- // v3.13.11 (issue #50.2): for non-auto-increment PKs (user-supplied
793
- // string IDs like "GC-100"), decide INSERT vs UPDATE on row
794
- // existence, not on whether the PK is set. Pre-v3.13.11 a
795
- // natural-key save() always chose UPDATE → matched zero rows →
796
- // silently returned success without inserting anything.
797
- //
798
- // Auto-increment behaviour is unchanged: pkValue is null/undefined
799
- // → INSERT, pkValue is set → UPDATE.
800
- let isUpdate = false;
801
- if (pkValue !== undefined && pkValue !== null) {
802
- if (pkField?.autoIncrement) {
803
- isUpdate = true;
804
- } else {
805
- try {
806
- // This asked exists(pkValue), which tests only the FIRST key column.
807
- // On a composite key that is true for any row sharing that column, so
808
- // inserting a genuinely NEW row was decided to be an UPDATE and
809
- // silently OVERWROTE a different row: saving (acme, a2) rewrote
810
- // (acme, a1). The check has to name the whole key, like the write
811
- // that follows it.
812
- const probe = this.pkWhere();
813
- if (ModelClass.getPkFields().length > 1 && probe.sql) {
814
- const found = await db.fetch(
815
- `SELECT 1 AS present FROM ${ModelClass.tableName} WHERE ${probe.sql}`,
816
- probe.params,
817
- 1,
818
- );
819
- isUpdate = (found as unknown as { length: number }).length > 0;
820
- } else {
821
- isUpdate = await ModelClass.exists(pkValue);
822
- }
823
- } catch {
824
- // If we can't tell (e.g. table doesn't exist yet), fall back
825
- // to INSERT so the user sees the real driver error rather
826
- // than a silent no-op.
827
- isUpdate = false;
828
- }
829
- }
830
- }
831
-
832
- // ── Canonical #2: validate() is enforced. An invalid model never reaches
833
- // the driver — fail loud (log + lastError), return false. Feature 19: on an
834
- // UPDATE the partial-update mode (isUpdate) is passed so an unset field is
835
- // not spuriously "required" (the persisted row already carries it), while a
836
- // field that IS present stays held to its type/length/pattern/range rules.
837
- const errors = this.validate(isUpdate);
838
- if (errors.length > 0) {
839
- this.lastError = errors.join("; ");
840
- Log.error(
841
- `${ModelClass.name}.save() refused: validation failed for table ` +
842
- `'${ModelClass.tableName}' — ${this.lastError}`,
843
- );
844
- return false;
845
- }
792
+ const isUpdate = await resolveSaveMode(this, ModelClass, db, pk, pkValue, pkField);
793
+ if (!validateBeforeSave(this, ModelClass, isUpdate)) return false;
846
794
 
847
795
  await adapterStartTransaction(db);
848
796
  try {
849
797
  if (isUpdate) {
850
- // Update keyed on the WHOLE primary key (see pkWhere).
851
- const updateFields = Object.entries(ModelClass.fields).filter(
852
- ([name, def]) => !def.primaryKey && this[name] !== undefined,
853
- );
854
- if (updateFields.length === 0) { await adapterCommit(db); return this; }
855
-
856
- const setClause = updateFields.map(([k]) => `"${ModelClass.getDbColumn(k)}" = ?`).join(", ");
857
- // The key params come from pkWhere() below; appending pkValue here too
858
- // would bind the first key column twice and shift every placeholder.
859
- const values = [...updateFields.map(([k, def]) => toDbFieldValue(def, this[k]))];
860
-
861
- // Keyed on the WHOLE primary key: one column of a composite key matches
862
- // every row sharing that value.
863
- const uw = this.pkWhere();
864
- values.push(...uw.params);
865
- await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE ${uw.sql}`, values);
798
+ await executeModelUpdate(this, ModelClass, db);
866
799
  } else {
867
- // Insert
868
- //
869
- // #165: an INSERT must OMIT a column the caller never assigned so a
870
- // `NOT NULL DEFAULT <x>` column gets its DB default instead of an
871
- // explicit NULL that violates the constraint. In TS an unset column is
872
- // `undefined` (the constructor only seeds a field that declares a
873
- // non-null ORM default — everything else stays `undefined`), so the
874
- // `this[name] !== undefined` filter already drops unset columns; a
875
- // value the caller set to `null` IS included and written as an explicit
876
- // NULL (parity with Python's _assigned_fields split — JS distinguishes
877
- // undefined-unset from null-explicit natively). A resolved non-null ORM
878
- // default is still written (no regression).
879
- const insertFields = Object.entries(ModelClass.fields).filter(
880
- ([name, def]) => !(def.primaryKey && def.autoIncrement) && this[name] !== undefined,
881
- );
882
-
883
- // For auto-increment PKs on engines that need it (PostgreSQL),
884
- // RETURNING the PK column lets us read the engine-assigned id back.
885
- // SQLite ignores the RETURNING clause harmlessly (it supports it
886
- // since 3.35) and we still prefer its lastInsertRowid; for other
887
- // engines extractLastInsertId() reads rows[0].id.
888
- const wantReturning = pkField?.autoIncrement && db.constructor.name !== "SQLiteAdapter";
889
- const returningClause = wantReturning ? ` RETURNING "${pkCol}"` : "";
890
-
891
- let insertSql: string;
892
- let values: unknown[];
893
- if (insertFields.length === 0) {
894
- // #165: every insertable column was left unset — let the DB apply ALL
895
- // its column defaults rather than emitting an empty column list
896
- // (`() VALUES ()` is invalid on SQLite/PostgreSQL/Firebird/MSSQL, which
897
- // spell the all-defaults insert `DEFAULT VALUES`; only MySQL uses the
898
- // empty-parens form). Mirrors the Python master's engine split.
899
- insertSql =
900
- (db.constructor.name === "MysqlAdapter"
901
- ? `INSERT INTO "${ModelClass.tableName}" () VALUES ()`
902
- : `INSERT INTO "${ModelClass.tableName}" DEFAULT VALUES`) + returningClause;
903
- values = [];
904
- } else {
905
- const columns = insertFields.map(([k]) => `"${ModelClass.getDbColumn(k)}"`).join(", ");
906
- const placeholders = insertFields.map(() => "?").join(", ");
907
- values = insertFields.map(([k, def]) => toDbFieldValue(def, this[k]));
908
- insertSql =
909
- `INSERT INTO "${ModelClass.tableName}" (${columns}) VALUES (${placeholders})` +
910
- returningClause;
911
- }
912
-
913
- const result = await adapterExecute(db, insertSql, values);
914
-
915
- // v3.13.11 (issue #50.2): only adopt the engine-assigned ID
916
- // for auto-increment PKs. A natural-key PK was already set by
917
- // the caller; don't overwrite it with the driver's last_id.
918
- if (pkField?.autoIncrement) {
919
- // RETURNING result: pg puts it in rows[0][pkCol]; normalise here.
920
- // string is allowed for a non-integer PK surfaced by lastInsertId()
921
- // (e.g. a PostgreSQL UUID PK) — #256.
922
- let newId: number | bigint | string | null = extractLastInsertId(result);
923
- if (newId === null && result && typeof result === "object") {
924
- const rows = (result as any).rows;
925
- if (Array.isArray(rows) && rows[0]) {
926
- newId = rows[0][pkCol] ?? rows[0].id ?? null;
927
- }
928
- }
929
- if (newId === null) {
930
- // Fall back to the adapter's tracked last id (MySQL/MSSQL).
931
- newId = db.lastInsertId();
932
- }
933
- if (newId !== null && newId !== undefined) {
934
- this[pk] = newId;
935
- }
936
- }
800
+ await executeModelInsert(this, ModelClass, db, pk, pkCol, pkField);
937
801
  }
938
802
  await adapterCommit(db);
939
- } catch (e: any) {
803
+ } catch (e: unknown) {
940
804
  await adapterRollback(db);
941
- // ── Canonical #1: fail loud, never silent. Keep the false return
942
- // contract, but capture the REAL cause (prefer the adapter's
943
- // getError()/getLastError() when present, falling back to the exception
944
- // text) on this.lastError so it survives, and log it with model/table
945
- // context. ──
946
- const adapterErr =
947
- typeof (db as any).getError === "function" ? (db as any).getError() :
948
- typeof (db as any).getLastError === "function" ? (db as any).getLastError() :
949
- null;
950
- let cause = adapterErr || e?.message || String(e);
951
- // ── DX hint (v3.13.60 parity with the Python master): turn a bare driver
952
- // error into an actionable fix for the two commonest ORM write footguns.
953
- // Matched case-insensitively (SQLite via node:sqlite: "no such table" /
954
- // "no such column: is_deleted" / "has no column named is_deleted";
955
- // Postgres/MySQL: "does not exist" / "doesn't exist" / "unknown column").
956
- // Any OTHER error keeps its raw cause untouched so an unrelated failure
957
- // (NOT NULL, duplicate PK) is never masked. ──
958
- const low = cause.toLowerCase();
959
- if (
960
- ModelClass.softDelete && low.includes("is_deleted") &&
961
- (low.includes("no such column") || low.includes("has no column") ||
962
- low.includes("does not exist") || low.includes("doesn't exist") ||
963
- low.includes("unknown column"))
964
- ) {
965
- cause +=
966
- " — softDelete=true needs an is_deleted column; declare it " +
967
- "(is_deleted: { type: 'integer', default: 0 }), boot the server so " +
968
- "syncModels() adds it, or run a migration";
969
- } else if (
970
- low.includes("no such table") ||
971
- ((low.includes("does not exist") || low.includes("doesn't exist")) &&
972
- !low.includes("column"))
973
- ) {
974
- cause +=
975
- ` — table '${ModelClass.tableName}' does not exist; call ` +
976
- `${ModelClass.name}.createTable() or run a migration`;
977
- }
978
- this.lastError = cause;
979
- Log.error(
980
- `${ModelClass.name}.save() failed for table ` +
981
- `'${ModelClass.tableName}': ${this.lastError}`,
982
- );
983
- return false;
805
+ return saveDatabaseFailure(this, ModelClass, db, e);
984
806
  }
985
807
  // Success — clear any previously-recorded error.
986
808
  this.lastError = null;
@@ -1045,88 +867,9 @@ export class BaseModel {
1045
867
  */
1046
868
  toDict(include?: string[], case_: "camel" | "snake" = "camel"): Record<string, unknown> {
1047
869
  const ModelClass = this.constructor as typeof BaseModel;
1048
- const result: Record<string, unknown> = {};
1049
- for (const key of Object.keys(ModelClass.fields)) {
1050
- if (this[key] !== undefined) {
1051
- const outKey = case_ === "snake" ? (ModelClass.fieldMapping[key] ?? key) : key;
1052
- result[outKey] = this[key] instanceof Point ? (this[key] as Point).geojson : this[key];
1053
- }
1054
- }
1055
- // Include soft delete field
1056
- if (ModelClass.softDelete && this.is_deleted !== undefined) {
1057
- result.is_deleted = this.is_deleted;
1058
- }
1059
-
1060
- if (include) {
1061
- // Group includes: top-level and nested
1062
- const topLevel: Record<string, string[]> = {};
1063
- for (const inc of include) {
1064
- const parts = inc.split(".", 2);
1065
- const relName = parts[0];
1066
- if (!topLevel[relName]) {
1067
- topLevel[relName] = [];
1068
- }
1069
- if (parts.length > 1) {
1070
- topLevel[relName].push(parts[1]);
1071
- }
1072
- }
1073
-
1074
- for (const [relName, nested] of Object.entries(topLevel)) {
1075
- // toDict stays synchronous (used in routes, templates, serialization).
1076
- // Relationships must be eager-loaded first (await Model._eagerLoad / the
1077
- // include arg on find/all/where) which fills _relCache. A relation that
1078
- // isn't cached is simply skipped here — async lazy-load on a sync
1079
- // serializer is not possible after the Option A async refactor.
1080
- const data = this._relCache[relName];
1081
- if (data === undefined) {
1082
- // LOAD-NODE-SERIALIZE-OMIT (feature 26, 3.13.99): the omission used
1083
- // to be completely silent — a developer who forgot `include` on the
1084
- // finder that produced this instance got a serialized object
1085
- // missing the relation with no signal at all. Warn (never throw —
1086
- // serialization must keep working) naming the model, the relation,
1087
- // and the fix, so the gap is visible instead of a quiet data loss.
1088
- Log.warning(
1089
- `${ModelClass.name}.toDict(): relation "${relName}" was requested via ` +
1090
- `include but was never eager-loaded (a synchronous serializer cannot ` +
1091
- `lazy-load it), so it is OMITTED from the result. Pass ` +
1092
- `include: ["${relName}"] to the finder (find/all/where/select/load) ` +
1093
- `that produced this instance.`,
1094
- );
1095
- continue;
1096
- }
1097
- if (data === null || data === undefined) {
1098
- result[relName] = null;
1099
- } else if (Array.isArray(data)) {
1100
- result[relName] = (data as BaseModel[]).map((r) =>
1101
- r.toDict(nested.length > 0 ? nested : undefined, case_),
1102
- );
1103
- } else if (typeof (data as BaseModel).toDict === "function") {
1104
- result[relName] = (data as BaseModel).toDict(
1105
- nested.length > 0 ? nested : undefined, case_,
1106
- );
1107
- }
1108
- }
1109
- } else {
1110
- // Legacy: include any relationship data already loaded on instance
1111
- if (ModelClass.hasOne) {
1112
- for (const rel of ModelClass.hasOne) {
1113
- const relKey = rel.model.toLowerCase();
1114
- if (this[relKey] !== undefined) {
1115
- result[relKey] = this[relKey];
1116
- }
1117
- }
1118
- }
1119
- if (ModelClass.hasMany) {
1120
- for (const rel of ModelClass.hasMany) {
1121
- const base = rel.model.toLowerCase();
1122
- const relKey = _pluralRelKeys() ? base + "s" : base;
1123
- if (this[relKey] !== undefined) {
1124
- result[relKey] = this[relKey];
1125
- }
1126
- }
1127
- }
1128
- }
1129
-
870
+ const result = serializeModelFields(this, ModelClass, case_);
871
+ if (include) serializeIncludedRelations(this, ModelClass, result, include, case_);
872
+ else serializeLoadedRelations(this, ModelClass, result);
1130
873
  return result;
1131
874
  }
1132
875
 
@@ -1209,99 +952,16 @@ export class BaseModel {
1209
952
  if (pointFields.length > 0) SQLTranslator.requireSpatial(engine, "PointField");
1210
953
  if (await adapterTableExists(db, this.tableName)) return this.createSpatialIndexes(db, pointFields);
1211
954
 
1212
- // Prefer the adapter's createTable — every adapter implements it and the
1213
- // async variants (PostgreSQL/MySQL/MSSQL/Firebird) emit engine-aware DDL
1214
- // (datetime → TIMESTAMP, boolean → native BOOLEAN, auto-increment → SERIAL
1215
- // etc. on PG). Remap field keys to DB column names if fieldMapping exists.
1216
- if (typeof db.createTable === "function" || typeof (db as any).createTableAsync === "function") {
1217
- const mappedFields: Record<string, FieldDefinition> = {};
1218
- for (const [fieldName, def] of Object.entries(this.fields)) {
1219
- const dbCol = this.getDbColumn(fieldName);
1220
- // A callable default (e.g. `default: () => new Date()`) is resolved per-row
1221
- // in the constructor; it must NOT reach the adapter DDL builder, where it
1222
- // stringifies to an invalid `DEFAULT () => ...` and silently fails table
1223
- // creation. Drop the default key so no adapter emits it (parity with Python #61).
1224
- if (typeof def.default === "function") {
1225
- const { default: _callableDefault, ...rest } = def;
1226
- mappedFields[dbCol] = rest;
1227
- } else {
1228
- mappedFields[dbCol] = def;
1229
- }
1230
- }
1231
- // SOFTDEL-DEC-02: a softDelete model needs an is_deleted flag column, but
1232
- // createTable() built the schema from DECLARED fields only — so a
1233
- // softDelete = true model that never declared is_deleted produced a table
1234
- // with NO such column, and every soft-delete read/write then errored on
1235
- // the missing column. syncModels() adds it on boot; createTable() must too.
1236
- // Inject it (INTEGER 0/1, default 0) unless the model already declares it.
1237
- if (this.softDelete && !("is_deleted" in mappedFields)) {
1238
- mappedFields["is_deleted"] = { type: "integer", default: 0 };
1239
- }
955
+ if (hasAdapterCreateTable(db)) {
956
+ const mappedFields = mappedCreateTableFields(this);
957
+ addSoftDeleteField(this, mappedFields);
1240
958
  await adapterCreateTable(db, this.tableName, mappedFields);
1241
959
  return this.createSpatialIndexes(db, pointFields);
1242
960
  }
1243
961
 
1244
962
  // Fallback: build SQL manually (SQLite-only dialect — used only when an
1245
963
  // adapter lacks createTable, which none currently do).
1246
- const typeMap: Record<string, string> = {
1247
- integer: "INTEGER",
1248
- string: "TEXT",
1249
- text: "TEXT",
1250
- number: "REAL",
1251
- numeric: "REAL",
1252
- boolean: "INTEGER",
1253
- datetime: "TEXT",
1254
- };
1255
-
1256
- const colDefs: string[] = [];
1257
- for (const [fieldName, def] of Object.entries(this.fields)) {
1258
- const dbCol = this.getDbColumn(fieldName);
1259
- const sqlType = typeMap[def.type] || "TEXT";
1260
- const parts = [`"${dbCol}" ${sqlType}`];
1261
- // A COMPOSITE key is declared ONCE, at table level (below). An inline
1262
- // PRIMARY KEY per column is invalid DDL - SQLite, PostgreSQL and MySQL
1263
- // all reject two of them in one table.
1264
- if (def.primaryKey && this.getPkFields().length === 1) parts.push("PRIMARY KEY");
1265
- if (def.autoIncrement) parts.push("AUTOINCREMENT");
1266
- if (def.required && !def.primaryKey) parts.push("NOT NULL");
1267
- // A callable default (e.g. `default: () => new Date()`) is resolved per-row
1268
- // in the constructor above; it must NOT be emitted into the CREATE TABLE
1269
- // DDL, where String(fn) stringifies to `DEFAULT () => ...` — invalid SQL
1270
- // that silently fails table creation (parity with Python #61).
1271
- if (def.default !== undefined && typeof def.default !== "function") {
1272
- const dv = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
1273
- parts.push(`DEFAULT ${dv}`);
1274
- }
1275
- colDefs.push(parts.join(" "));
1276
- }
1277
-
1278
- // SOFTDEL-DEC-02 (fallback path): inject the is_deleted flag column for a
1279
- // softDelete model that did not declare it, mirroring the adapter path above.
1280
- if (this.softDelete) {
1281
- const dbCols = Object.keys(this.fields).map((f) => this.getDbColumn(f));
1282
- if (!dbCols.includes("is_deleted")) {
1283
- colDefs.push(`"is_deleted" INTEGER DEFAULT 0`);
1284
- }
1285
- }
1286
-
1287
- // A COMPOSITE key is declared ONCE, at table level; the per-column inline
1288
- // form above is suppressed for it, because two inline primary keys is
1289
- // invalid DDL on every engine.
1290
- const pkFields = this.getPkFields();
1291
- if (pkFields.length > 1) {
1292
- const pkCols = pkFields.map((f) => this.getDbColumn(f));
1293
- colDefs.push(`PRIMARY KEY (${pkCols.join(", ")})`);
1294
- }
1295
-
1296
- const sql = `CREATE TABLE IF NOT EXISTS "${this.tableName}" (${colDefs.join(", ")})`;
1297
- await adapterStartTransaction(db);
1298
- try {
1299
- await adapterExecute(db, sql);
1300
- await adapterCommit(db);
1301
- } catch (e) {
1302
- await adapterRollback(db);
1303
- throw e;
1304
- }
964
+ await createFallbackTable(db, this);
1305
965
  return true;
1306
966
  }
1307
967
 
@@ -1875,167 +1535,9 @@ export class BaseModel {
1875
1535
  // Post._processForeignKeys() never ran. _applyFkRegistry() then merges the
1876
1536
  // registered hasMany entries onto each model.
1877
1537
  BaseModel._processAllForeignKeys();
1878
-
1879
- // Group includes: top-level and nested
1880
- const topLevel: Record<string, string[]> = {};
1881
- for (const inc of include) {
1882
- const parts = inc.split(".", 2);
1883
- const relName = parts[0];
1884
- if (!topLevel[relName]) {
1885
- topLevel[relName] = [];
1886
- }
1887
- if (parts.length > 1) {
1888
- topLevel[relName].push(parts[1]);
1889
- }
1890
- }
1891
-
1538
+ const topLevel = groupIncludedRelations(include);
1892
1539
  for (const [relName, nested] of Object.entries(topLevel)) {
1893
- // Find the relationship definition.
1894
- //
1895
- // Include names are resolved case-insensitively against each candidate
1896
- // relation. Accepted forms (for a relation to model "Post" on table "posts"):
1897
- // - the model name → "Post" / "post"
1898
- // - the auto/related key → "post" (singular) or "posts" (when
1899
- // TINA4_ORM_PLURAL_TABLE_NAMES is enabled)
1900
- // - the table name → "posts"
1901
- // All matching is lower-cased so "Post", "post" and "posts" all resolve.
1902
- const want = relName.toLowerCase();
1903
- let relDef: RelationshipDefinition | undefined;
1904
- let relType: "hasOne" | "hasMany" | "belongsTo" | null = null;
1905
-
1906
- const matchesModel = (r: RelationshipDefinition): boolean => {
1907
- const base = r.model.toLowerCase();
1908
- const related = BaseModel._modelRegistry[r.model];
1909
- const table = related?.tableName?.toLowerCase();
1910
- // Outlier F: an FK-auto-wired has-many carries its derived key
1911
- // (declaring class lowercased + "s", or relatedName) — match it so
1912
- // include: ["posts"] resolves to the wired relation regardless of the
1913
- // related table name.
1914
- const rel = r.relatedName?.toLowerCase();
1915
- return (
1916
- base === want ||
1917
- base + "s" === want ||
1918
- (rel !== undefined && rel === want) ||
1919
- (table !== undefined && table === want)
1920
- );
1921
- };
1922
-
1923
- if (ModelClass.hasOne) {
1924
- relDef = ModelClass.hasOne.find(matchesModel);
1925
- if (relDef) relType = "hasOne";
1926
- }
1927
- if (!relDef && ModelClass.hasMany) {
1928
- relDef = ModelClass.hasMany.find(matchesModel);
1929
- if (relDef) relType = "hasMany";
1930
- }
1931
- if (!relDef && ModelClass.belongsTo) {
1932
- relDef = ModelClass.belongsTo.find(matchesModel);
1933
- if (relDef) relType = "belongsTo";
1934
- }
1935
-
1936
- if (!relDef || !relType) {
1937
- // Don't silently skip — a typo'd or unknown include name is almost
1938
- // always a developer mistake. Surface it so it's visible.
1939
- Log.warning(
1940
- `eager-load: include "${relName}" did not match any relationship on ` +
1941
- `${ModelClass.name} (table "${ModelClass.tableName}"). ` +
1942
- `Accepted forms are the related model name, its singular/plural ` +
1943
- `key, or the related table name (case-insensitive).`,
1944
- );
1945
- continue;
1946
- }
1947
-
1948
- const relatedClass = BaseModel._modelRegistry[relDef.model];
1949
- if (!relatedClass) continue;
1950
-
1951
- const db = relatedClass.getDb();
1952
- const fk = relDef.foreignKey;
1953
-
1954
- if (relType === "hasOne" || relType === "hasMany") {
1955
- const pk = ModelClass.getPkField();
1956
- const pkValues = instances
1957
- .map((inst) => inst[pk])
1958
- .filter((v) => v !== undefined && v !== null);
1959
- if (pkValues.length === 0) continue;
1960
-
1961
- // REL-EAGER-UNBOUNDED: chunk the parent PKs so the IN list stays bounded
1962
- // (each chunk is one query). adapterQuery is uncapped, so no row cap.
1963
- const related: BaseModel[] = [];
1964
- for (const pkChunk of _chunk(pkValues, EAGER_IN_CHUNK)) {
1965
- const placeholders = pkChunk.map(() => "?").join(",");
1966
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${fk}" IN (${placeholders})`;
1967
- if (relatedClass.softDelete) {
1968
- sql += ` AND is_deleted = 0`;
1969
- }
1970
- const rows = await adapterQuery(db, sql, pkChunk);
1971
- for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
1972
- }
1973
-
1974
- // Eager load nested
1975
- if (nested.length > 0 && related.length > 0) {
1976
- await relatedClass._eagerLoad(related, nested);
1977
- }
1978
-
1979
- // Group by FK — fk is a DB column name, resolve to JS property name on the related model
1980
- const relatedReverseMap = relatedClass.getReverseMapping();
1981
- const fkProp = relatedReverseMap[fk] ?? fk;
1982
- const grouped: Record<string, BaseModel[]> = {};
1983
- for (const record of related) {
1984
- const fkVal = _joinKey(record[fkProp]);
1985
- if (!grouped[fkVal]) grouped[fkVal] = [];
1986
- grouped[fkVal].push(record);
1987
- }
1988
-
1989
- for (const inst of instances) {
1990
- const pkVal = _joinKey(inst[pk]);
1991
- const records = grouped[pkVal] || [];
1992
- if (relType === "hasOne") {
1993
- inst._relCache[relName] = records[0] ?? null;
1994
- } else {
1995
- inst._relCache[relName] = records;
1996
- }
1997
- }
1998
- } else if (relType === "belongsTo") {
1999
- // fk is a DB column name on the current model — resolve to JS property name
2000
- const ownerReverseMap = ModelClass.getReverseMapping();
2001
- const fkProp = ownerReverseMap[fk] ?? fk;
2002
- const fkValues = [...new Set(
2003
- instances
2004
- .map((inst) => inst[fkProp])
2005
- .filter((v) => v !== undefined && v !== null),
2006
- )];
2007
- if (fkValues.length === 0) continue;
2008
-
2009
- const relatedPk = relatedClass.getPkField();
2010
- const relatedPkCol = relatedClass.getPkColumn();
2011
- // REL-EAGER-UNBOUNDED: chunk the FK values so the IN list stays bounded.
2012
- const related: BaseModel[] = [];
2013
- for (const fkChunk of _chunk(fkValues, EAGER_IN_CHUNK)) {
2014
- const placeholders = fkChunk.map(() => "?").join(",");
2015
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${relatedPkCol}" IN (${placeholders})`;
2016
- if (relatedClass.softDelete) {
2017
- sql += ` AND is_deleted = 0`;
2018
- }
2019
- const rows = await adapterQuery(db, sql, fkChunk);
2020
- for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
2021
- }
2022
-
2023
- if (nested.length > 0 && related.length > 0) {
2024
- await relatedClass._eagerLoad(related, nested);
2025
- }
2026
-
2027
- const lookup: Record<string, BaseModel> = {};
2028
- for (const record of related) {
2029
- lookup[_joinKey(record[relatedPk])] = record;
2030
- }
2031
-
2032
- for (const inst of instances) {
2033
- const fkVal = inst[fkProp];
2034
- inst._relCache[relName] = fkVal !== undefined && fkVal !== null
2035
- ? lookup[_joinKey(fkVal)] ?? null
2036
- : null;
2037
- }
2038
- }
1540
+ await eagerLoadRelation(instances, ModelClass, relName, nested);
2039
1541
  }
2040
1542
  }
2041
1543
 
@@ -2062,3 +1564,415 @@ export class BaseModel {
2062
1564
  this._relCache = {};
2063
1565
  }
2064
1566
  }
1567
+
1568
+ function savePrimaryKeyWhere(instance: BaseModel, model: typeof BaseModel): { sql: string; params: unknown[] } {
1569
+ const params: unknown[] = [];
1570
+ const clauses = primaryKeyFields(model).map((field) => {
1571
+ params.push(instance[field]);
1572
+ return `${model.getDbColumn(field)} = ?`;
1573
+ });
1574
+ return { sql: clauses.join(" AND "), params };
1575
+ }
1576
+
1577
+ async function resolveSaveMode(
1578
+ instance: BaseModel,
1579
+ model: typeof BaseModel,
1580
+ db: DatabaseAdapter,
1581
+ pk: string,
1582
+ pkValue: unknown,
1583
+ pkField: FieldDefinition | undefined,
1584
+ ): Promise<boolean> {
1585
+ if (pkValue === undefined || pkValue === null || pkField?.autoIncrement) return pkValue !== undefined && pkValue !== null;
1586
+ try {
1587
+ const where = savePrimaryKeyWhere(instance, model);
1588
+ if (primaryKeyFields(model).length > 1) {
1589
+ const found = await db.fetch(`SELECT 1 AS present FROM ${model.tableName} WHERE ${where.sql}`, where.params, 1);
1590
+ return (found as unknown as { length: number }).length > 0;
1591
+ }
1592
+ return await model.exists(pkValue);
1593
+ } catch {
1594
+ return false;
1595
+ }
1596
+ }
1597
+
1598
+ function validateBeforeSave(instance: BaseModel, model: typeof BaseModel, isUpdate: boolean): boolean {
1599
+ const errors = instance.validate(isUpdate);
1600
+ if (errors.length === 0) return true;
1601
+ instance.lastError = errors.join("; ");
1602
+ Log.error(`${model.name}.save() refused: validation failed for table '${model.tableName}' — ${instance.lastError}`);
1603
+ return false;
1604
+ }
1605
+
1606
+ async function executeModelUpdate(instance: BaseModel, model: typeof BaseModel, db: DatabaseAdapter): Promise<void> {
1607
+ const fields = Object.entries(model.fields).filter(([name, def]) => !def.primaryKey && instance[name] !== undefined);
1608
+ if (fields.length === 0) return;
1609
+ const setClause = fields.map(([name]) => `"${model.getDbColumn(name)}" = ?`).join(", ");
1610
+ const values = fields.map(([name, def]) => toDbFieldValue(def, instance[name]));
1611
+ const where = savePrimaryKeyWhere(instance, model);
1612
+ values.push(...where.params);
1613
+ await adapterExecute(db, `UPDATE "${model.tableName}" SET ${setClause} WHERE ${where.sql}`, values);
1614
+ }
1615
+
1616
+ function buildInsertStatement(
1617
+ instance: BaseModel,
1618
+ model: typeof BaseModel,
1619
+ db: DatabaseAdapter,
1620
+ pkField: FieldDefinition | undefined,
1621
+ pkCol: string,
1622
+ ): { sql: string; values: unknown[] } {
1623
+ const fields = Object.entries(model.fields).filter(
1624
+ ([name, def]) => !(def.primaryKey && def.autoIncrement) && instance[name] !== undefined,
1625
+ );
1626
+ const returning = pkField?.autoIncrement && db.constructor.name !== "SQLiteAdapter" ? ` RETURNING "${pkCol}"` : "";
1627
+ if (fields.length === 0) {
1628
+ const emptyInsert = db.constructor.name === "MysqlAdapter"
1629
+ ? `INSERT INTO "${model.tableName}" () VALUES ()`
1630
+ : `INSERT INTO "${model.tableName}" DEFAULT VALUES`;
1631
+ return { sql: emptyInsert + returning, values: [] };
1632
+ }
1633
+ const columns = fields.map(([name]) => `"${model.getDbColumn(name)}"`).join(", ");
1634
+ const placeholders = fields.map(() => "?").join(", ");
1635
+ const values = fields.map(([name, def]) => toDbFieldValue(def, instance[name]));
1636
+ return { sql: `INSERT INTO "${model.tableName}" (${columns}) VALUES (${placeholders})${returning}`, values };
1637
+ }
1638
+
1639
+ function applyInsertedId(
1640
+ instance: BaseModel,
1641
+ db: DatabaseAdapter,
1642
+ result: unknown,
1643
+ pk: string,
1644
+ pkCol: string,
1645
+ ): void {
1646
+ let newId: number | bigint | string | null = extractLastInsertId(result);
1647
+ if (newId === null && result && typeof result === "object") {
1648
+ const rows = (result as { rows?: Array<Record<string, unknown>> }).rows;
1649
+ if (Array.isArray(rows) && rows[0]) newId = (rows[0][pkCol] ?? rows[0].id ?? null) as typeof newId;
1650
+ }
1651
+ if (newId === null) newId = db.lastInsertId();
1652
+ if (newId !== null && newId !== undefined) instance[pk] = newId;
1653
+ }
1654
+
1655
+ async function executeModelInsert(
1656
+ instance: BaseModel,
1657
+ model: typeof BaseModel,
1658
+ db: DatabaseAdapter,
1659
+ pk: string,
1660
+ pkCol: string,
1661
+ pkField: FieldDefinition | undefined,
1662
+ ): Promise<void> {
1663
+ const statement = buildInsertStatement(instance, model, db, pkField, pkCol);
1664
+ const result = await adapterExecute(db, statement.sql, statement.values);
1665
+ if (pkField?.autoIncrement) applyInsertedId(instance, db, result, pk, pkCol);
1666
+ }
1667
+
1668
+ function saveDatabaseFailure(instance: BaseModel, model: typeof BaseModel, db: DatabaseAdapter, error: unknown): false {
1669
+ const adapter = db as any;
1670
+ const adapterError = typeof adapter.getError === "function" ? adapter.getError() :
1671
+ typeof adapter.getLastError === "function" ? adapter.getLastError() : null;
1672
+ let cause = String(adapterError || (error instanceof Error ? error.message : error));
1673
+ const low = cause.toLowerCase();
1674
+ if (model.softDelete && low.includes("is_deleted") &&
1675
+ ["no such column", "has no column", "does not exist", "doesn't exist", "unknown column"].some((part) => low.includes(part))) {
1676
+ cause += " — softDelete=true needs an is_deleted column; declare it (is_deleted: { type: 'integer', default: 0 }), boot the server so syncModels() adds it, or run a migration";
1677
+ } else if (low.includes("no such table") ||
1678
+ ((low.includes("does not exist") || low.includes("doesn't exist")) && !low.includes("column"))) {
1679
+ cause += ` — table '${model.tableName}' does not exist; call ${model.name}.createTable() or run a migration`;
1680
+ }
1681
+ instance.lastError = cause;
1682
+ Log.error(`${model.name}.save() failed for table '${model.tableName}': ${instance.lastError}`);
1683
+ return false;
1684
+ }
1685
+
1686
+ function serializeModelFields(instance: BaseModel, model: typeof BaseModel, case_: "camel" | "snake"): Record<string, unknown> {
1687
+ const result: Record<string, unknown> = {};
1688
+ for (const key of Object.keys(model.fields)) {
1689
+ if (instance[key] === undefined) continue;
1690
+ const outKey = case_ === "snake" ? (model.fieldMapping[key] ?? key) : key;
1691
+ result[outKey] = instance[key] instanceof Point ? (instance[key] as Point).geojson : instance[key];
1692
+ }
1693
+ if (model.softDelete && instance.is_deleted !== undefined) result.is_deleted = instance.is_deleted;
1694
+ return result;
1695
+ }
1696
+
1697
+ function groupIncludedRelations(include: string[]): Record<string, string[]> {
1698
+ const grouped: Record<string, string[]> = {};
1699
+ for (const item of include) {
1700
+ const parts = item.split(".", 2);
1701
+ grouped[parts[0]] ??= [];
1702
+ if (parts.length > 1) grouped[parts[0]].push(parts[1]);
1703
+ }
1704
+ return grouped;
1705
+ }
1706
+
1707
+ function serializeIncludedRelation(
1708
+ instance: BaseModel,
1709
+ model: typeof BaseModel,
1710
+ result: Record<string, unknown>,
1711
+ relName: string,
1712
+ nested: string[],
1713
+ case_: "camel" | "snake",
1714
+ ): void {
1715
+ const cache = (instance as unknown as { _relCache: Record<string, unknown> })._relCache;
1716
+ const data = cache[relName];
1717
+ if (data === undefined) {
1718
+ Log.warning(
1719
+ `${model.name}.toDict(): relation "${relName}" was requested via include but was never eager-loaded ` +
1720
+ `(a synchronous serializer cannot lazy-load it), so it is OMITTED from the result. Pass ` +
1721
+ `include: ["${relName}"] to the finder (find/all/where/select/load) that produced this instance.`,
1722
+ );
1723
+ return;
1724
+ }
1725
+ if (data === null) {
1726
+ result[relName] = null;
1727
+ return;
1728
+ }
1729
+ const nestedInclude = nested.length > 0 ? nested : undefined;
1730
+ if (Array.isArray(data)) {
1731
+ result[relName] = (data as BaseModel[]).map((item) => item.toDict(nestedInclude, case_));
1732
+ return;
1733
+ }
1734
+ if (typeof (data as BaseModel).toDict === "function") {
1735
+ result[relName] = (data as BaseModel).toDict(nestedInclude, case_);
1736
+ }
1737
+ }
1738
+
1739
+ function serializeIncludedRelations(
1740
+ instance: BaseModel,
1741
+ model: typeof BaseModel,
1742
+ result: Record<string, unknown>,
1743
+ include: string[],
1744
+ case_: "camel" | "snake",
1745
+ ): void {
1746
+ const grouped = groupIncludedRelations(include);
1747
+ for (const [relName, nested] of Object.entries(grouped)) {
1748
+ serializeIncludedRelation(instance, model, result, relName, nested, case_);
1749
+ }
1750
+ }
1751
+
1752
+ function serializeLoadedRelations(instance: BaseModel, model: typeof BaseModel, result: Record<string, unknown>): void {
1753
+ for (const relation of model.hasOne ?? []) {
1754
+ const key = relation.model.toLowerCase();
1755
+ if (instance[key] !== undefined) result[key] = instance[key];
1756
+ }
1757
+ for (const relation of model.hasMany ?? []) {
1758
+ const base = relation.model.toLowerCase();
1759
+ const key = _pluralRelKeys() ? base + "s" : base;
1760
+ if (instance[key] !== undefined) result[key] = instance[key];
1761
+ }
1762
+ }
1763
+
1764
+ type EagerRelation = {
1765
+ definition: RelationshipDefinition;
1766
+ type: "hasOne" | "hasMany" | "belongsTo";
1767
+ };
1768
+
1769
+ function findEagerRelation(model: typeof BaseModel, name: string): EagerRelation | undefined {
1770
+ const want = name.toLowerCase();
1771
+ const matches = (relation: RelationshipDefinition): boolean => {
1772
+ const base = relation.model.toLowerCase();
1773
+ const related = BaseModel._modelRegistry[relation.model];
1774
+ const table = related?.tableName?.toLowerCase();
1775
+ const relatedName = relation.relatedName?.toLowerCase();
1776
+ return base === want || base + "s" === want || relatedName === want || table === want;
1777
+ };
1778
+ const candidates: Array<[RelationshipDefinition[] | undefined, EagerRelation["type"]]> = [
1779
+ [model.hasOne, "hasOne"],
1780
+ [model.hasMany, "hasMany"],
1781
+ [model.belongsTo, "belongsTo"],
1782
+ ];
1783
+ for (const [relations, type] of candidates) {
1784
+ const definition = relations?.find(matches);
1785
+ if (definition) return { definition, type };
1786
+ }
1787
+ return undefined;
1788
+ }
1789
+
1790
+ function relationshipCache(instance: BaseModel): Record<string, unknown> {
1791
+ return (instance as unknown as { _relCache: Record<string, unknown> })._relCache;
1792
+ }
1793
+
1794
+ function reverseMappedColumn(model: typeof BaseModel, column: string): string {
1795
+ return model.getReverseMapping()[column] ?? column;
1796
+ }
1797
+
1798
+ async function eagerQueryRelated(
1799
+ relatedClass: typeof BaseModel,
1800
+ column: string,
1801
+ values: unknown[],
1802
+ ): Promise<BaseModel[]> {
1803
+ const db = (relatedClass as unknown as { getDb(): DatabaseAdapter }).getDb();
1804
+ const related: BaseModel[] = [];
1805
+ for (const chunk of _chunk(values, EAGER_IN_CHUNK)) {
1806
+ const placeholders = chunk.map(() => "?").join(",");
1807
+ let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${column}" IN (${placeholders})`;
1808
+ if (relatedClass.softDelete) sql += " AND is_deleted = 0";
1809
+ const rows = await adapterQuery(db, sql, chunk);
1810
+ for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
1811
+ }
1812
+ return related;
1813
+ }
1814
+
1815
+ async function eagerLoadHasRelation(
1816
+ instances: BaseModel[],
1817
+ model: typeof BaseModel,
1818
+ relatedClass: typeof BaseModel,
1819
+ relationName: string,
1820
+ nested: string[],
1821
+ relation: RelationshipDefinition,
1822
+ type: "hasOne" | "hasMany",
1823
+ ): Promise<void> {
1824
+ const pk = primaryKeyFields(model)[0];
1825
+ const values = instances.map((instance) => instance[pk]).filter((value) => value !== undefined && value !== null);
1826
+ if (values.length === 0) return;
1827
+ const related = await eagerQueryRelated(relatedClass, relation.foreignKey, values);
1828
+ if (nested.length > 0 && related.length > 0) await relatedClass._eagerLoad(related, nested);
1829
+ const fkProp = reverseMappedColumn(relatedClass, relation.foreignKey);
1830
+ const grouped: Record<string, BaseModel[]> = {};
1831
+ for (const record of related) {
1832
+ const key = _joinKey(record[fkProp]);
1833
+ (grouped[key] ??= []).push(record);
1834
+ }
1835
+ for (const instance of instances) {
1836
+ const records = grouped[_joinKey(instance[pk])] ?? [];
1837
+ relationshipCache(instance)[relationName] = type === "hasOne" ? records[0] ?? null : records;
1838
+ }
1839
+ }
1840
+
1841
+ async function eagerLoadBelongsToRelation(
1842
+ instances: BaseModel[],
1843
+ model: typeof BaseModel,
1844
+ relatedClass: typeof BaseModel,
1845
+ relationName: string,
1846
+ nested: string[],
1847
+ relation: RelationshipDefinition,
1848
+ ): Promise<void> {
1849
+ const fkProp = reverseMappedColumn(model, relation.foreignKey);
1850
+ const values = [...new Set(instances.map((instance) => instance[fkProp]).filter((value) => value !== undefined && value !== null))];
1851
+ if (values.length === 0) return;
1852
+ const relatedPk = primaryKeyFields(relatedClass)[0];
1853
+ const relatedPkColumn = relatedClass.getDbColumn(relatedPk);
1854
+ const related = await eagerQueryRelated(relatedClass, relatedPkColumn, values);
1855
+ if (nested.length > 0 && related.length > 0) await relatedClass._eagerLoad(related, nested);
1856
+ const lookup: Record<string, BaseModel> = {};
1857
+ for (const record of related) lookup[_joinKey(record[relatedPk])] = record;
1858
+ for (const instance of instances) {
1859
+ const value = instance[fkProp];
1860
+ relationshipCache(instance)[relationName] = value !== undefined && value !== null
1861
+ ? lookup[_joinKey(value)] ?? null
1862
+ : null;
1863
+ }
1864
+ }
1865
+
1866
+ async function eagerLoadRelation(
1867
+ instances: BaseModel[],
1868
+ model: typeof BaseModel,
1869
+ relationName: string,
1870
+ nested: string[],
1871
+ ): Promise<void> {
1872
+ const found = findEagerRelation(model, relationName);
1873
+ if (!found) {
1874
+ Log.warning(
1875
+ `eager-load: include "${relationName}" did not match any relationship on ${model.name} ` +
1876
+ `(table "${model.tableName}"). Accepted forms are the related model name, its ` +
1877
+ `singular/plural key, or the related table name (case-insensitive).`,
1878
+ );
1879
+ return;
1880
+ }
1881
+ const relatedClass = BaseModel._modelRegistry[found.definition.model];
1882
+ if (!relatedClass) return;
1883
+ if (found.type === "belongsTo") {
1884
+ await eagerLoadBelongsToRelation(instances, model, relatedClass, relationName, nested, found.definition);
1885
+ return;
1886
+ }
1887
+ await eagerLoadHasRelation(instances, model, relatedClass, relationName, nested, found.definition, found.type);
1888
+ }
1889
+
1890
+ /** Whether the adapter can emit engine-specific CREATE TABLE DDL. */
1891
+ function hasAdapterCreateTable(db: DatabaseAdapter): boolean {
1892
+ return typeof db.createTable === "function" || typeof (db as any).createTableAsync === "function";
1893
+ }
1894
+
1895
+ /**
1896
+ * Map model fields to adapter column names while removing callable defaults.
1897
+ * Callable defaults are resolved per row by the model constructor and must not
1898
+ * be stringified into adapter DDL.
1899
+ */
1900
+ function mappedCreateTableFields(model: typeof BaseModel): Record<string, FieldDefinition> {
1901
+ const mapped: Record<string, FieldDefinition> = {};
1902
+ for (const [fieldName, def] of Object.entries(model.fields)) {
1903
+ const dbCol = model.getDbColumn(fieldName);
1904
+ if (typeof def.default === "function") {
1905
+ const { default: _callableDefault, ...rest } = def;
1906
+ mapped[dbCol] = rest;
1907
+ } else {
1908
+ mapped[dbCol] = def;
1909
+ }
1910
+ }
1911
+ return mapped;
1912
+ }
1913
+
1914
+ /** Add the implicit soft-delete column when a model has not declared it. */
1915
+ function addSoftDeleteField(model: typeof BaseModel, fields: Record<string, FieldDefinition>): void {
1916
+ if (model.softDelete && !("is_deleted" in fields)) {
1917
+ fields.is_deleted = { type: "integer", default: 0 };
1918
+ }
1919
+ }
1920
+
1921
+ function primaryKeyFields(model: typeof BaseModel): string[] {
1922
+ const keys = Object.entries(model.fields)
1923
+ .filter(([, def]) => def.primaryKey)
1924
+ .map(([name]) => name);
1925
+ return keys.length > 0 ? keys : ["id"];
1926
+ }
1927
+
1928
+ const FALLBACK_COLUMN_TYPES: Record<string, string> = {
1929
+ integer: "INTEGER",
1930
+ string: "TEXT",
1931
+ text: "TEXT",
1932
+ number: "REAL",
1933
+ numeric: "REAL",
1934
+ boolean: "INTEGER",
1935
+ datetime: "TEXT",
1936
+ };
1937
+
1938
+ function fallbackColumnDefinition(model: typeof BaseModel, fieldName: string, def: FieldDefinition): string {
1939
+ const dbCol = model.getDbColumn(fieldName);
1940
+ const parts = [`"${dbCol}" ${FALLBACK_COLUMN_TYPES[def.type] || "TEXT"}`];
1941
+ if (def.primaryKey && primaryKeyFields(model).length === 1) parts.push("PRIMARY KEY");
1942
+ if (def.autoIncrement) parts.push("AUTOINCREMENT");
1943
+ if (def.required && !def.primaryKey) parts.push("NOT NULL");
1944
+ if (def.default !== undefined && typeof def.default !== "function") {
1945
+ const value = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
1946
+ parts.push(`DEFAULT ${value}`);
1947
+ }
1948
+ return parts.join(" ");
1949
+ }
1950
+
1951
+ function fallbackPrimaryKeyDefinition(model: typeof BaseModel): string | undefined {
1952
+ const pkFields = primaryKeyFields(model);
1953
+ if (pkFields.length <= 1) return undefined;
1954
+ const pkCols = pkFields.map((fieldName) => model.getDbColumn(fieldName));
1955
+ return `PRIMARY KEY (${pkCols.join(", ")})`;
1956
+ }
1957
+
1958
+ function fallbackColumnDefinitions(model: typeof BaseModel): string[] {
1959
+ const definitions = Object.entries(model.fields).map(([fieldName, def]) => fallbackColumnDefinition(model, fieldName, def));
1960
+ const dbCols = Object.keys(model.fields).map((fieldName) => model.getDbColumn(fieldName));
1961
+ if (model.softDelete && !dbCols.includes("is_deleted")) definitions.push('"is_deleted" INTEGER DEFAULT 0');
1962
+ const primaryKey = fallbackPrimaryKeyDefinition(model);
1963
+ if (primaryKey) definitions.push(primaryKey);
1964
+ return definitions;
1965
+ }
1966
+
1967
+ async function createFallbackTable(db: DatabaseAdapter, model: typeof BaseModel): Promise<void> {
1968
+ const definitions = fallbackColumnDefinitions(model);
1969
+ const sql = `CREATE TABLE IF NOT EXISTS "${model.tableName}" (${definitions.join(", ")})`;
1970
+ await adapterStartTransaction(db);
1971
+ try {
1972
+ await adapterExecute(db, sql);
1973
+ await adapterCommit(db);
1974
+ } catch (e) {
1975
+ await adapterRollback(db);
1976
+ throw e;
1977
+ }
1978
+ }