lamix 4.2.11 → 4.2.12

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/lib/index.js CHANGED
@@ -232,31 +232,51 @@ class DB {
232
232
 
233
233
  try {
234
234
  const result = await executor();
235
+
235
236
  this._emit('query', {
236
237
  sql,
237
238
  bindings,
238
239
  time: performance.now() - start,
239
240
  driver: this.driver
240
241
  });
242
+
241
243
  return result;
242
244
  } catch (err) {
243
- this._emit('error', { err, sql, bindings, attempt });
245
+ const isConnectionError =
246
+ err?.code === 'PROTOCOL_CONNECTION_LOST' ||
247
+ err?.code === 'ECONNRESET' ||
248
+ err?.code === 'ECONNREFUSED' ||
249
+ err?.code === 'ETIMEDOUT' ||
250
+ /dead|lost|timeout|reset|closed|connect/i.test(err.message);
251
+
252
+ this._emit('error', {
253
+ err,
254
+ sql,
255
+ bindings,
256
+ attempt,
257
+ connectionError: isConnectionError
258
+ });
244
259
 
245
260
  const canRetry =
246
- !isWrite ||
247
- this._als.getStore()?.inTransaction;
248
-
249
- if (
250
- canRetry &&
251
- attempt < this.retryAttempts &&
252
- /dead|lost|timeout|reset|closed/i.test(err.message)
253
- ) {
261
+ (!isWrite || this._als.getStore()?.inTransaction) &&
262
+ isConnectionError;
263
+
264
+ if (canRetry && attempt < this.retryAttempts) {
254
265
  await this.reconnect();
255
266
  await sleep(100 * attempt);
256
267
  continue;
257
268
  }
258
269
 
259
- throw new DBError('DB query failed', { sql, bindings, err });
270
+ throw new DBError(
271
+ isConnectionError ? 'DB connection failed' : 'DB query failed',
272
+ {
273
+ sql,
274
+ bindings,
275
+ attempt,
276
+ connectionError: isConnectionError,
277
+ err
278
+ }
279
+ );
260
280
  }
261
281
  }
262
282
  }
@@ -1146,9 +1166,13 @@ class QueryBuilder {
1146
1166
 
1147
1167
  // Helper to normalize operator
1148
1168
  _normalizeOperator(operator) {
1149
- const op = operator ? operator.toUpperCase() : '=';
1169
+ const op = operator ? operator.toUpperCase() : '='
1150
1170
  if (!VALID_OPERATORS.includes(op)) {
1151
- throw new Error(`Invalid SQL operator: ${operator}`);
1171
+ throw new DBError('Invalid SQL operator', {
1172
+ operator,
1173
+ normalized: op,
1174
+ method: '_normalizeOperator'
1175
+ });
1152
1176
  }
1153
1177
 
1154
1178
  // Convert ILIKE to proper operator for MySQL
@@ -1348,7 +1372,13 @@ class QueryBuilder {
1348
1372
  }
1349
1373
 
1350
1374
  whereIn(column, values = []) {
1351
- if (!Array.isArray(values)) throw new Error('whereIn expects array');
1375
+ if (!Array.isArray(values)) {
1376
+ throw new DBError('whereIn expects an array', {
1377
+ method: 'whereIn',
1378
+ column,
1379
+ values
1380
+ });
1381
+ }
1352
1382
  if (!values.length) {
1353
1383
  return this._pushWhere({ type: 'raw', raw: '0 = 1', bindings: [] });
1354
1384
  }
@@ -1391,7 +1421,13 @@ class QueryBuilder {
1391
1421
  }
1392
1422
 
1393
1423
  whereNotIn(column, values = []) {
1394
- if (!Array.isArray(values)) throw new Error('whereNotIn expects array');
1424
+ if (!Array.isArray(values)) {
1425
+ throw new DBError('whereNotIn expects an array', {
1426
+ method: 'whereNotIn',
1427
+ column,
1428
+ values
1429
+ });
1430
+ }
1395
1431
  if (!values.length) {
1396
1432
  return this._pushWhere({ type: 'raw', raw: '1 = 1', bindings: [] });
1397
1433
  }
@@ -1576,7 +1612,14 @@ class QueryBuilder {
1576
1612
  whereHas(relationName, callback, boolean = 'AND') {
1577
1613
  const relation = this.modelClass.relations()?.[relationName];
1578
1614
  if (!relation) {
1579
- throw new Error(`Relation '${relationName}' is not defined on ${this.modelClass.name}`);
1615
+ throw new DBError(
1616
+ `Relation '${relationName}' is not defined`,
1617
+ {
1618
+ model: this.modelClass?.name,
1619
+ relation: relationName,
1620
+ method: 'whereHas'
1621
+ }
1622
+ );
1580
1623
  }
1581
1624
 
1582
1625
  const RelatedModel = relation.model();
@@ -1641,6 +1684,16 @@ class QueryBuilder {
1641
1684
  parts.push('SELECT');
1642
1685
  if (this._distinct) parts.push('DISTINCT');
1643
1686
 
1687
+ // Normalize SELECT * to table.* when a model is attached
1688
+ if (
1689
+ this.modelClass &&
1690
+ this._select.length === 1 &&
1691
+ this._select[0] === '*'
1692
+ ) {
1693
+ const table = this.tableAlias || this.table;
1694
+ this._select = [`${escapeId(table)}.*`];
1695
+ }
1696
+
1644
1697
  parts.push(this._select.length ? this._select.join(', ') : '*');
1645
1698
 
1646
1699
  if (this._fromRaw) {
@@ -1785,7 +1838,10 @@ class QueryBuilder {
1785
1838
  break;
1786
1839
 
1787
1840
  default:
1788
- throw new Error('Unknown where type: ' + w.type);
1841
+ throw new DBError('Unknown where clause type', {
1842
+ type: w.type,
1843
+ where: w
1844
+ });
1789
1845
  }
1790
1846
  });
1791
1847
 
@@ -1823,20 +1879,29 @@ class QueryBuilder {
1823
1879
  const sql = this._compileSelect();
1824
1880
  const binds = this._gatherBindings();
1825
1881
 
1826
- const rows = await DB.raw(sql, binds);
1882
+ try {
1883
+ const rows = await DB.raw(sql, binds);
1827
1884
 
1828
- if (this.modelClass) {
1829
- const models = rows.map(r => new this.modelClass(r, true));
1885
+ if (this.modelClass) {
1886
+ const models = rows.map(r => new this.modelClass(r, true));
1830
1887
 
1831
- if (this._with.length) {
1832
- const loaded = await this._eagerLoad(models);
1833
- return new Collection(loaded);
1888
+ if (this._with.length) {
1889
+ const loaded = await this._eagerLoad(models);
1890
+ return new Collection(loaded);
1891
+ }
1892
+
1893
+ return new Collection(models);
1834
1894
  }
1835
1895
 
1836
- return new Collection(models);
1896
+ return new Collection(rows);
1897
+ } catch (err) {
1898
+ throw new DBError('Select query failed', {
1899
+ table: this.table,
1900
+ sql,
1901
+ bindings: binds,
1902
+ err
1903
+ });
1837
1904
  }
1838
-
1839
- return new Collection(rows);
1840
1905
  }
1841
1906
 
1842
1907
  async first() {
@@ -1849,7 +1914,13 @@ class QueryBuilder {
1849
1914
 
1850
1915
  async firstOrFail() {
1851
1916
  const r = await this.first();
1852
- if (!r) throw new Error('Record not found');
1917
+ if (!r) {
1918
+ throw new DBError('Record not found', {
1919
+ method: 'firstOrFail',
1920
+ table: this.table,
1921
+ model: this.modelClass?.name
1922
+ });
1923
+ }
1853
1924
  return r;
1854
1925
  }
1855
1926
 
@@ -1860,10 +1931,19 @@ class QueryBuilder {
1860
1931
  c.limit(1);
1861
1932
 
1862
1933
  const sql = c._compileSelect();
1863
- const b = c._gatherBindings();
1934
+ const bindings = c._gatherBindings();
1864
1935
 
1865
- const rows = await DB.raw(sql, b);
1866
- return rows.length > 0;
1936
+ try {
1937
+ const rows = await DB.raw(sql, bindings);
1938
+ return rows.length > 0;
1939
+ } catch (err) {
1940
+ throw new DBError('Exists query failed', {
1941
+ table: this.table,
1942
+ sql,
1943
+ bindings,
1944
+ err
1945
+ });
1946
+ }
1867
1947
  }
1868
1948
 
1869
1949
  async doesntExist() {
@@ -1878,11 +1958,19 @@ class QueryBuilder {
1878
1958
  c._offset = null;
1879
1959
 
1880
1960
  const sql = c._compileSelect();
1881
- const b = c._gatherBindings();
1961
+ const bindings = c._gatherBindings();
1882
1962
 
1883
- const rows = await DB.raw(sql, b);
1884
-
1885
- return rows[0] ? Number(rows[0].aggregate) : 0;
1963
+ try {
1964
+ const rows = await DB.raw(sql, bindings);
1965
+ return rows[0] ? Number(rows[0].aggregate) : 0;
1966
+ } catch (err) {
1967
+ throw new DBError('Count query failed', {
1968
+ table: this.table,
1969
+ sql,
1970
+ bindings,
1971
+ err
1972
+ });
1973
+ }
1886
1974
  }
1887
1975
 
1888
1976
  async _aggregate(expr) {
@@ -1893,11 +1981,20 @@ class QueryBuilder {
1893
1981
  c._offset = null;
1894
1982
 
1895
1983
  const sql = c._compileSelect();
1896
- const b = c._gatherBindings();
1984
+ const bindings = c._gatherBindings();
1897
1985
 
1898
- const rows = await DB.raw(sql, b);
1899
-
1900
- return rows[0] ? Number(rows[0].aggregate) : 0;
1986
+ try {
1987
+ const rows = await DB.raw(sql, bindings);
1988
+ return rows[0] ? Number(rows[0].aggregate) : 0;
1989
+ } catch (err) {
1990
+ throw new DBError('Aggregate query failed', {
1991
+ table: this.table,
1992
+ expression: expr,
1993
+ sql,
1994
+ bindings,
1995
+ err
1996
+ });
1997
+ }
1901
1998
  }
1902
1999
 
1903
2000
  sum(c) { return this._aggregate(`SUM(${escapeId(c)})`); }
@@ -1911,36 +2008,51 @@ class QueryBuilder {
1911
2008
  c._select = [escapeId(col)];
1912
2009
 
1913
2010
  const sql = c._compileSelect();
1914
- const b = c._gatherBindings();
1915
-
1916
- const rows = await DB.raw(sql, b);
2011
+ const bindings = c._gatherBindings();
1917
2012
 
1918
- return rows.map(r => r[col]);
2013
+ try {
2014
+ const rows = await DB.raw(sql, bindings);
2015
+ return rows.map(r => r[col]);
2016
+ } catch (err) {
2017
+ throw new DBError('Pluck query failed', {
2018
+ table: this.table,
2019
+ column: col,
2020
+ sql,
2021
+ bindings,
2022
+ err
2023
+ });
2024
+ }
1919
2025
  }
1920
2026
 
1921
2027
  async paginate(page = 1, perPage = 15) {
1922
2028
  page = Math.max(1, Number(page));
1923
2029
  perPage = Math.max(1, Number(perPage));
1924
2030
 
1925
- // Build a clone to compute total (safe — uses your existing _clone and count)
1926
- const countClone = this._clone();
1927
- countClone._select = [`COUNT(*) AS aggregate`];
1928
- countClone._orders = [];
1929
- countClone._limit = null;
1930
- countClone._offset = null;
1931
-
1932
- const total = await countClone.count('*');
1933
-
1934
- const offset = (page - 1) * perPage;
1935
-
1936
- // Use a clone to fetch rows so we don't mutate caller's builder
1937
- const rows = await this._clone()
1938
- .limit(perPage)
1939
- .offset(offset)
1940
- .get();
1941
-
1942
- // Return Paginator instance with .toJSON()
1943
- return new Paginator(rows, total, page, perPage);
2031
+ try {
2032
+ const countClone = this._clone();
2033
+ countClone._select = [`COUNT(*) AS aggregate`];
2034
+ countClone._orders = [];
2035
+ countClone._limit = null;
2036
+ countClone._offset = null;
2037
+
2038
+ const total = await countClone.count('*');
2039
+
2040
+ const offset = (page - 1) * perPage;
2041
+
2042
+ const rows = await this._clone()
2043
+ .limit(perPage)
2044
+ .offset(offset)
2045
+ .get();
2046
+
2047
+ return new Paginator(rows, total, page, perPage);
2048
+ } catch (err) {
2049
+ throw new DBError('Pagination failed', {
2050
+ table: this.table,
2051
+ page,
2052
+ perPage,
2053
+ err
2054
+ });
2055
+ }
1944
2056
  }
1945
2057
 
1946
2058
  /**************************************************************************
@@ -1955,10 +2067,18 @@ class QueryBuilder {
1955
2067
  `) VALUES (${placeholders})`;
1956
2068
 
1957
2069
  const bindings = Object.values(values);
1958
-
1959
- const result = await DB.raw(sql, bindings);
1960
-
1961
- return result.affectedRows || 0;
2070
+ try {
2071
+ const result = await DB.raw(sql, bindings);
2072
+
2073
+ return result.affectedRows || 0;
2074
+ } catch (err) {
2075
+ throw new DBError('Insert failed', {
2076
+ table: this.table,
2077
+ sql,
2078
+ bindings,
2079
+ err
2080
+ });
2081
+ }
1962
2082
  }
1963
2083
 
1964
2084
  async insertGetId(values) {
@@ -1970,15 +2090,22 @@ class QueryBuilder {
1970
2090
  `) VALUES (${placeholders})`;
1971
2091
 
1972
2092
  const bindings = Object.values(values);
1973
-
1974
- const result = await DB.raw(sql, bindings);
1975
-
1976
- return result.insertId ?? null;
2093
+ try {
2094
+ const result = await DB.raw(sql, bindings);
2095
+
2096
+ return result.insertId ?? null;
2097
+ } catch (err) {
2098
+ throw new DBError('Insert (get ID) failed', {
2099
+ table: this.table,
2100
+ sql,
2101
+ bindings,
2102
+ err
2103
+ });
2104
+ }
1977
2105
  }
1978
2106
 
1979
2107
  async update(values) {
1980
2108
  if (!Object.keys(values).length) return 0;
1981
-
1982
2109
  const setClause = Object.keys(values)
1983
2110
  .map(k => `${escapeId(k)} = ?`)
1984
2111
  .join(', ');
@@ -1988,10 +2115,18 @@ class QueryBuilder {
1988
2115
  `UPDATE ${escapeId(this.table)} SET ${setClause} ${whereSql}`;
1989
2116
 
1990
2117
  const bindings = [...Object.values(values), ...this._gatherBindings()];
1991
-
1992
- const result = await DB.raw(sql, bindings);
1993
-
1994
- return result.affectedRows || 0;
2118
+ try {
2119
+ const result = await DB.raw(sql, bindings);
2120
+
2121
+ return result.affectedRows || 0;
2122
+ } catch (err) {
2123
+ throw new DBError('Update failed', {
2124
+ table: this.table,
2125
+ sql,
2126
+ bindings,
2127
+ err
2128
+ });
2129
+ }
1995
2130
  }
1996
2131
 
1997
2132
  async increment(col, by = 1) {
@@ -2000,11 +2135,20 @@ class QueryBuilder {
2000
2135
  `SET ${escapeId(col)} = ${escapeId(col)} + ? ` +
2001
2136
  this._compileWhereOnly();
2002
2137
 
2003
- const b = [by, ...this._gatherBindings()];
2004
-
2005
- const res = await DB.raw(sql, b);
2138
+ const bindings = [by, ...this._gatherBindings()];
2006
2139
 
2007
- return res.affectedRows || 0;
2140
+ try {
2141
+ const res = await DB.raw(sql, bindings);
2142
+ return res.affectedRows || 0;
2143
+ } catch (err) {
2144
+ throw new DBError('Increment failed', {
2145
+ table: this.table,
2146
+ column: col,
2147
+ sql,
2148
+ bindings,
2149
+ err
2150
+ });
2151
+ }
2008
2152
  }
2009
2153
 
2010
2154
  async decrement(col, by = 1) {
@@ -2013,9 +2157,20 @@ class QueryBuilder {
2013
2157
  `SET ${escapeId(col)} = ${escapeId(col)} - ? ` +
2014
2158
  this._compileWhereOnly();
2015
2159
 
2016
- const b = [by, ...this._gatherBindings()];
2017
- const res = await DB.raw(sql, b);
2018
- return res.affectedRows || 0;
2160
+ const bindings = [by, ...this._gatherBindings()];
2161
+
2162
+ try {
2163
+ const res = await DB.raw(sql, bindings);
2164
+ return res.affectedRows || 0;
2165
+ } catch (err) {
2166
+ throw new DBError('Decrement failed', {
2167
+ table: this.table,
2168
+ column: col,
2169
+ sql,
2170
+ bindings,
2171
+ err
2172
+ });
2173
+ }
2019
2174
  }
2020
2175
 
2021
2176
  async delete() {
@@ -2023,16 +2178,34 @@ class QueryBuilder {
2023
2178
  `DELETE FROM ${escapeId(this.table)} ` +
2024
2179
  this._compileWhereOnly();
2025
2180
 
2026
- const b = this._gatherBindings();
2181
+ const bindings = this._gatherBindings();
2027
2182
 
2028
- const res = await DB.raw(sql, b);
2029
- return res.affectedRows || 0;
2183
+ try {
2184
+ const res = await DB.raw(sql, bindings);
2185
+ return res.affectedRows || 0;
2186
+ } catch (err) {
2187
+ throw new DBError('Delete failed', {
2188
+ table: this.table,
2189
+ sql,
2190
+ bindings,
2191
+ err
2192
+ });
2193
+ }
2030
2194
  }
2031
2195
 
2032
2196
  async truncate() {
2033
2197
  const sql = `TRUNCATE TABLE ${escapeId(this.table)}`;
2034
- await DB.raw(sql);
2035
- return true;
2198
+
2199
+ try {
2200
+ await DB.raw(sql);
2201
+ return true;
2202
+ } catch (err) {
2203
+ throw new DBError('Truncate failed', {
2204
+ table: this.table,
2205
+ sql,
2206
+ err
2207
+ });
2208
+ }
2036
2209
  }
2037
2210
 
2038
2211
  _compileWhereOnly() {
@@ -2044,19 +2217,23 @@ class QueryBuilder {
2044
2217
  * EAGER LOAD (unchanged except robust checks)
2045
2218
  **************************************************************************/
2046
2219
  async _eagerLoad(models) {
2047
- for (const relName of this._with) {
2048
- const sample = models[0];
2049
- if (!sample) return models;
2220
+ if (!models.length) return models;
2050
2221
 
2051
- const relationMethod = sample[relName];
2052
- if (typeof relationMethod !== 'function') {
2053
- throw new Error(`Relation "${relName}" is not a method on ${sample.constructor.name}`);
2054
- }
2222
+ for (const relName of this._with) {
2223
+ const sample = models.find(m => typeof m[relName] === 'function');
2224
+ if (!sample) continue;
2055
2225
 
2056
- const relation = relationMethod.call(sample);
2226
+ const relation = sample[relName]();
2057
2227
 
2058
2228
  if (!relation || typeof relation.eagerLoad !== 'function') {
2059
- throw new Error(`Relation "${relName}" does not have a valid eagerLoad method`);
2229
+ throw new DBError(
2230
+ `Relation "${relName}" is not eager-loadable`,
2231
+ {
2232
+ model: sample.constructor.name,
2233
+ relation: relName,
2234
+ method: 'eagerLoad'
2235
+ }
2236
+ );
2060
2237
  }
2061
2238
 
2062
2239
  await relation.eagerLoad(models, relName);
@@ -2802,28 +2979,57 @@ class Model {
2802
2979
  this._relations = {};
2803
2980
  this._exists = !!fresh;
2804
2981
 
2805
- // Only store keys with defined values
2982
+ // ──────────────────────────────
2983
+ // 1️⃣ hydrate attributes safely
2984
+ // ──────────────────────────────
2806
2985
  for (const [k, v] of Object.entries(attributes)) {
2807
- if (v !== undefined) this._attributes[k] = v;
2986
+ if (v !== undefined) {
2987
+ this._attributes[k] = v;
2988
+ }
2989
+ }
2990
+
2991
+ // ──────────────────────────────
2992
+ // 2️⃣ ensure timestamps ALWAYS exist
2993
+ // ──────────────────────────────
2994
+ if (this.constructor.timestamps) {
2995
+ if ('created_at' in attributes)
2996
+ this._attributes.created_at = attributes.created_at;
2997
+
2998
+ if ('updated_at' in attributes)
2999
+ this._attributes.updated_at = attributes.updated_at;
3000
+ }
3001
+
3002
+ // ──────────────────────────────
3003
+ // 3️⃣ always keep primary key
3004
+ // ──────────────────────────────
3005
+ const pk = this.constructor.primaryKey;
3006
+ if (pk && pk in attributes) {
3007
+ this._attributes[pk] = attributes[pk];
2808
3008
  }
2809
3009
 
2810
3010
  this._original = { ...this._attributes, ...data };
2811
3011
 
2812
- // Define getters for attributes
3012
+ // ──────────────────────────────
3013
+ // 4️⃣ define getters/setters FOR ALL ATTRIBUTES
3014
+ // ──────────────────────────────
2813
3015
  for (const k of Object.keys(this._attributes)) {
2814
3016
  if (!(k in this)) {
2815
3017
  Object.defineProperty(this, k, {
2816
- get: function() {
2817
- return this._attributes[k];
2818
- },
2819
- enumerable: true
3018
+ get: () => this._attributes[k],
3019
+ set: v => { this._attributes[k] = v; },
3020
+ enumerable: true,
3021
+ configurable: true
2820
3022
  });
2821
3023
  }
2822
3024
  }
2823
3025
  }
2824
3026
 
2825
3027
  static async validate(data, id, ignoreId = null) {
2826
- if (!Validator) throw new Error('Validator not found.');
3028
+ if (!Validator) {
3029
+ throw new DBError('Validator not found', {
3030
+ model: this.name
3031
+ });
3032
+ }
2827
3033
 
2828
3034
  const rules = this.rules || {};
2829
3035
 
@@ -2897,20 +3103,6 @@ class Model {
2897
3103
  }
2898
3104
  }
2899
3105
 
2900
- // ──────────────────────────────
2901
- // Query builder accessors
2902
- // ──────────────────────────────
2903
- // static query({ withTrashed = false } = {}) {
2904
- // // use tableName getter (throws if missing)
2905
- // const qb = new QueryBuilder(this.tableName, this);
2906
- // if (this.softDeletes && !withTrashed) {
2907
- // // avoid mutating shared _wheres reference
2908
- // qb._wheres = Array.isArray(qb._wheres) ? qb._wheres.slice() : [];
2909
- // qb._wheres.push({ raw: `${DB.escapeId(this.deletedAt)} IS NULL` });
2910
- // }
2911
- // return qb;
2912
- // }
2913
-
2914
3106
  static query({ withTrashed = false } = {}) {
2915
3107
  const qb = new QueryBuilder(this.tableName, this);
2916
3108
 
@@ -3000,14 +3192,26 @@ class Model {
3000
3192
  }
3001
3193
 
3002
3194
  static async findManyBy(col, values = []) {
3003
- if (!Array.isArray(values)) throw new Error('findManyBy expects an array of values');
3195
+ if (!Array.isArray(values)) {
3196
+ throw new DBError('findManyBy expects an array of values', {
3197
+ method: 'findManyBy',
3198
+ column: col
3199
+ });
3200
+ }
3004
3201
  if (!values.length) return [];
3005
3202
  return await this.query().whereIn(col, values).get();
3006
3203
  }
3007
3204
 
3008
3205
  // additional common accessors
3009
3206
  static async findMany(ids = []) {
3010
- if (!Array.isArray(ids)) ids = [ids];
3207
+ // if (!Array.isArray(ids)) ids = [ids];
3208
+ if (!Array.isArray(ids)) {
3209
+ throw new DBError('findMany expects an array of IDs', {
3210
+ method: 'findMany',
3211
+ model: this.name,
3212
+ ids
3213
+ });
3214
+ }
3011
3215
  if (!ids.length) return [];
3012
3216
  return await this.query().whereIn(this.primaryKey, ids).get();
3013
3217
  }
@@ -3077,7 +3281,10 @@ class Model {
3077
3281
 
3078
3282
  // 3. Block empty payload
3079
3283
  if (!Object.keys(payload).length) {
3080
- throw new DBError('Attempted to create with empty payload');
3284
+ throw new DBError('Attempted to create with empty payload', {
3285
+ model: this.name,
3286
+ attrs
3287
+ });
3081
3288
  }
3082
3289
 
3083
3290
  // 4. Create + save
@@ -3164,9 +3371,9 @@ class Model {
3164
3371
  // 🛡 SANITIZATION UTIL
3165
3372
  // ──────────────────────────────
3166
3373
  static isBadValue(value) {
3167
- if (value === null || value === undefined || value === '') return true;
3168
- if (typeof value === 'string' && !value.trim()) return true;
3169
- return false;
3374
+ if (value === null || value === undefined) return true;
3375
+ if (typeof value === 'string' && !value.trim()) return true; // empty string
3376
+ return false; // allow 0, false, etc.
3170
3377
  }
3171
3378
 
3172
3379
  sanitize(attrs = {}) {
@@ -3177,7 +3384,7 @@ class Model {
3177
3384
  const val = attrs[key];
3178
3385
 
3179
3386
  if (!this.constructor.isBadValue(val)) {
3180
- clean[key] = val;
3387
+ clean[key] = val; // keep 0, false, etc.
3181
3388
  } else if (keepCols.includes(key)) {
3182
3389
  clean[key] = null;
3183
3390
  }
@@ -3191,13 +3398,24 @@ class Model {
3191
3398
  // ──────────────────────────────
3192
3399
  async fill(attrs = {}) {
3193
3400
  const allowed = this.constructor.fillable || Object.keys(attrs);
3401
+ let filled = false;
3194
3402
 
3195
3403
  for (const key of Object.keys(attrs)) {
3196
3404
  const val = attrs[key];
3197
- if (allowed.includes(key) && !this.constructor.isBadValue(val)) {
3198
- this._attributes[key] = val;
3405
+ if (allowed.includes(key) && val !== undefined) {
3406
+ this._attributes[key] = val; // 0, false, null preserved
3407
+ filled = true;
3199
3408
  }
3200
3409
  }
3410
+
3411
+ if (!filled && Object.keys(attrs).length > 0) {
3412
+ throw new DBError('No fillable attributes provided', {
3413
+ model: this.constructor.name,
3414
+ attrs,
3415
+ fillable: allowed
3416
+ });
3417
+ }
3418
+
3201
3419
  return this;
3202
3420
  }
3203
3421
 
@@ -3209,17 +3427,23 @@ class Model {
3209
3427
  // INSERT – validation first
3210
3428
  // ──────────────────────────────
3211
3429
  async saveNew(attrs) {
3430
+ if (!this._exists && !Object.keys(this._attributes).length) {
3431
+ throw new DBError('Cannot save empty model', {
3432
+ model: this.constructor.name
3433
+ });
3434
+ }
3435
+
3212
3436
  const payload = this.sanitize(attrs || this._attributes);
3213
3437
 
3214
- // Validate BEFORE hooks/db
3438
+ // validate BEFORE hooks/db
3215
3439
  await this.constructor.validate(payload);
3216
3440
  await this.trigger('creating');
3217
3441
 
3218
3442
  // timestamps
3219
3443
  if (this.constructor.timestamps) {
3220
3444
  const now = new Date();
3221
- payload.created_at = payload.created_at || now;
3222
- payload.updated_at = payload.updated_at || now;
3445
+ if (payload.created_at === undefined) payload.created_at = now;
3446
+ if (payload.updated_at === undefined) payload.updated_at = now;
3223
3447
  }
3224
3448
 
3225
3449
  // soft deletes
@@ -3231,13 +3455,11 @@ class Model {
3231
3455
  }
3232
3456
 
3233
3457
  const qb = this.constructor.query();
3234
-
3235
3458
  const result = await qb.insert(payload);
3236
3459
 
3237
3460
  // handle pk
3238
3461
  const pk = this.constructor.primaryKey;
3239
3462
  const insertId = Array.isArray(result) ? result[0] : result;
3240
-
3241
3463
  if (!(pk in payload) && insertId !== undefined) {
3242
3464
  payload[pk] = insertId;
3243
3465
  }
@@ -3248,12 +3470,16 @@ class Model {
3248
3470
 
3249
3471
  return this;
3250
3472
  }
3251
-
3252
3473
  // ──────────────────────────────
3253
3474
  // UPDATE – only dirty fields
3254
3475
  // ──────────────────────────────
3255
3476
  async save() {
3256
- if (!this._exists) return this.saveNew(this._attributes);
3477
+ // if (!this._exists) return this.saveNew(this._attributes);
3478
+ if (!this._exists && !Object.keys(this._attributes).length) {
3479
+ throw new DBError('Cannot save empty model', {
3480
+ model: this.constructor.name
3481
+ });
3482
+ }
3257
3483
 
3258
3484
  await this.trigger('updating');
3259
3485
 
@@ -3264,11 +3490,8 @@ class Model {
3264
3490
  for (const key of Object.keys(attrs)) {
3265
3491
  const val = attrs[key];
3266
3492
 
3267
- if (val !== orig[key] && !this.constructor.isBadValue(val)) {
3268
- if (this.constructor.softDeletes &&
3269
- key === this.constructor.deletedAt) {
3270
- continue;
3271
- }
3493
+ if (val !== orig[key] && val !== undefined) { // only treat undefined as "no change"
3494
+ if (this.constructor.softDeletes && key === this.constructor.deletedAt) continue;
3272
3495
  dirty[key] = val;
3273
3496
  }
3274
3497
  }
@@ -3285,14 +3508,10 @@ class Model {
3285
3508
 
3286
3509
  // validate BEFORE db write
3287
3510
  const pk = this.constructor.primaryKey;
3288
- await this.constructor.validate(
3289
- { ...this._original, ...payload },
3290
- this._attributes[pk]
3291
- );
3511
+ await this.constructor.validate({ ...this._original, ...payload }, this._attributes[pk]);
3292
3512
 
3293
3513
  const id = this._attributes[pk];
3294
3514
  const qb = this.constructor.query();
3295
-
3296
3515
  await qb.where(pk, id).update(payload);
3297
3516
 
3298
3517
  this._original = { ...this.sanitize(this._attributes) };
@@ -3326,8 +3545,13 @@ class Model {
3326
3545
  let rel;
3327
3546
  try {
3328
3547
  rel = fn.call(dummy);
3329
- } catch {
3330
- continue;
3548
+ } catch (err) {
3549
+ // continue;
3550
+ throw new DBError('Failed to resolve relation', {
3551
+ model: this.name,
3552
+ relation: name,
3553
+ err
3554
+ });
3331
3555
  }
3332
3556
 
3333
3557
  if (!rel) continue;
@@ -3395,8 +3619,13 @@ class Model {
3395
3619
  // RESTRICT — block delete
3396
3620
  // ──────────────────────────────────
3397
3621
  case 'restrict':
3398
- throw new Error(
3399
- `Cannot delete ${this.constructor.name}: related ${relName} exists`
3622
+ throw new DBError(
3623
+ `Cannot delete ${this.constructor.name}: related ${relName} exists`,
3624
+ {
3625
+ model: this.constructor.name,
3626
+ relation: relName,
3627
+ behavior: 'restrict'
3628
+ }
3400
3629
  );
3401
3630
 
3402
3631
  // ──────────────────────────────────
@@ -3458,7 +3687,14 @@ class Model {
3458
3687
 
3459
3688
 
3460
3689
  static async destroy(ids) {
3461
- if (!Array.isArray(ids)) ids = [ids];
3690
+ if (!Array.isArray(ids)) {
3691
+ throw new DBError('destroy expects an array of IDs', {
3692
+ model: this.name,
3693
+ ids
3694
+ });
3695
+ }
3696
+
3697
+ // if (!Array.isArray(ids)) ids = [ids];
3462
3698
  const pk = this.primaryKey;
3463
3699
 
3464
3700
  // --- Load models so cascade works ---
@@ -3618,7 +3854,12 @@ class Model {
3618
3854
 
3619
3855
  async refresh() {
3620
3856
  const pk = this.constructor.primaryKey;
3621
- if (!this._attributes[pk]) return this;
3857
+ // if (!this._attributes[pk]) return this;
3858
+ if (!this._attributes[pk]) {
3859
+ throw new DBError('Cannot refresh model without primary key', {
3860
+ model: this.constructor.name
3861
+ });
3862
+ }
3622
3863
  const fresh = await this.constructor.find(this._attributes[pk]);
3623
3864
  if (fresh) {
3624
3865
  this._attributes = { ...fresh._attributes };