@zerotal/orm 1.14.3 → 1.15.1

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/CHANGELOG.md CHANGED
@@ -8,6 +8,90 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.1] — 2026-09-13
12
+
13
+ ### Fixed
14
+
15
+ - **A model with a primary key other than `id` could not be updated, deleted or
16
+ reloaded.** `static primaryKey = "uuid"` (and the `@(table("…").primaryKey("…"))` chain)
17
+ configured the column name everywhere the key was written into SQL, but the value bound
18
+ against it came from `this.id` — a property such a model never has. Every statement
19
+ therefore went out as `WHERE uuid = NULL`: `save()` on a loaded instance and `delete()`
20
+ each matched zero rows and returned as though they had written, `refresh()` and `fresh()`
21
+ threw `ModelNotFoundError` for a row that was sitting in the table, and `increment()`,
22
+ `decrement()`, `loadCount()` and the relation aggregate loads no-opped or reported zero.
23
+
24
+ The insert was worse than a silent no-op. It wrote the row correctly, then read it back
25
+ by `last_insert_rowid()` / `LAST_INSERT_ID()`, which answer with a rowid that no `TEXT`
26
+ key ever equals — so the re-read matched nothing and the instance came back with no
27
+ timestamps, no database defaults, and still marked as not yet resident. The next `save()`
28
+ on it was a second INSERT and a duplicate-key error rather than the UPDATE the caller
29
+ asked for.
30
+
31
+ Every one of those paths now reads the key by its declared name, preferring the loaded
32
+ value so reassigning a key updates the row it was read from. An insert whose key the app
33
+ minted skips the generated-id round-trip and reads the row back by the key it just wrote.
34
+ `replicate()` drops the declared key instead of only `id`, so the copy is insertable.
35
+ A write against an instance whose key is genuinely absent — a row hydrated by
36
+ `select("title")`, say — now throws `E_NO_PRIMARY_KEY` instead of binding NULL and
37
+ looking like a success.
38
+
39
+ - **A primary key the app mints was stripped from the INSERT.** `id` was treated as
40
+ database-generated unconditionally, so a table whose key is a `TEXT id` the application
41
+ supplies took the value on the instance, dropped it from the statement, and stored NULL
42
+ while the in-memory model went on holding the value it thought it had written. The key is
43
+ now omitted only when it has no value, which is what leaves AUTOINCREMENT to the database.
44
+
45
+ - **Hardening: a MySQL adapter that offers `reserve()` may still refuse it.**
46
+ `LAST_INSERT_ID()` is scoped per connection, so a MySQL insert pins its INSERT and the
47
+ SELECT that reads the id to one connection, reserving one where the adapter supports it
48
+ and falling back to a short transaction where it does not. The check was
49
+ `typeof conn.reserve === "function"`, and presence is not support — an adapter can expose
50
+ the method and throw `"This adapter doesn't support connection reservation"` when it is
51
+ called, which is what Bun's SQLite adapter does.
52
+
53
+ The reservation is now attempted and the transaction path used when it refuses. Only the
54
+ acquisition is guarded: a failure inside the insert is a real error and is not retried
55
+ against a second connection. `try`/`catch` rather than `.catch()`, since an adapter that
56
+ refuses may throw synchronously.
57
+
58
+ No released version was affected — this path was only reachable through a test that had
59
+ leaked a dialect (see below). Changed because the detection was wrong either way.
60
+
61
+ - **A dialect-coverage test left the ORM in MySQL mode for the rest of the process.**
62
+ `server.smoke.test.ts` sets four globals in `beforeAll` and restored three — the
63
+ BaseModel dialect stayed on whatever server it had just tested. Every later file in that
64
+ process then took the MySQL insert branch, which reads the new row's id with
65
+ `LAST_INSERT_ID()`; against the SQLite connection those tests actually use, that is
66
+ `no such function`.
67
+
68
+ Test-only, and it never reached a release: the leak needs `ZT_MYSQL_URL` set, which
69
+ happens in one CI job. Postgres hid it for months — its branch uses `RETURNING`, which
70
+ SQLite also supports — so only the MySQL job could go red, and only when the file order
71
+ put the smoke test first. Bun walks test files in directory order, which is arbitrary on
72
+ Linux, so "when" was a coin flip on every run.
73
+
74
+ ## [1.15.0] — 2026-09-04
75
+
76
+ ### Changed — **BREAKING**
77
+
78
+ - **BREAKING: `createdAt`, `updatedAt` and `deletedAt` are `Carbon`, not `Date`.** Every
79
+ `datetime` column hydrates as `Carbon`, these three included — so the declared type was
80
+ wrong on a freshly loaded row, and `order.createdAt.getTime()` type-checked and threw,
81
+ because `Carbon` has `toISOString()` and `valueOf()` but no `getTime()`.
82
+
83
+ Worse, the runtime class depended on how you got there: `Carbon` after a load, a raw
84
+ `Date` after a `save()`, a `touch()`, or a soft `delete()` wrote one straight back onto
85
+ the instance. The same property on the same model, matching its declared type in neither
86
+ case, and with no `@column` declaration to check the truth against.
87
+
88
+ All three are now `Carbon` everywhere, declared and at runtime. Code calling a `Date`
89
+ method on them was already failing at runtime and now fails to compile; `.toISOString()`
90
+ gives a string, `.valueOf()` epoch milliseconds, and `.toDate()` a native `Date`.
91
+
92
+ Compile and follow the errors — every one is a line that would have thrown against a
93
+ loaded row. See the [Upgrade Guide](/docs/upgrade#114-to-115).
94
+
11
95
  ## [1.12.0] — 2026-08-31
12
96
 
13
97
  ### Changed — **BREAKING**
package/api-surface.md CHANGED
@@ -3,6 +3,9 @@
3
3
  <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
4
  Run `bun run api:surface` to regenerate after an intentional API change. -->
5
5
 
6
+ > Not a guide — this is a flat, alphabetical snapshot used to detect unintended
7
+ > API changes. For how to use these, read the [documentation](https://zerotal.dev/docs).
8
+
6
9
  ## . `(./src/index.ts)`
7
10
 
8
11
  class ArrayCast = {
@@ -78,7 +81,7 @@ class BaseModel = {
78
81
  $dirty: () => Record<string, unknown>
79
82
  append: (...keys: string[]) => BaseModel
80
83
  associate: (relation: string, model: BaseModel) => BaseModel
81
- createdAt?: Date
84
+ createdAt?: Carbon
82
85
  decrement: (column: 'id' | 'createdAt' | 'updatedAt' | '__isZerotalModel' | 'fill' | 'forceFill' | 'save' | 'delete' | 'load' | 'loadMissing' | 'fresh' | 'refresh' | 'replicate' | 'touch' | 'is' | 'isNot' | 'increment' | 'decrement' | 'loadCount' | 'loadSum' | 'loadAvg' | 'loadMin' | 'loadMax' | 'makeHidden' | 'makeVisible' | 'append' | 'associate' | 'dissociate' | 'isDirty' | '$dirty' | 'markDirty' | 'toJSON', amount?: number) => Promise<BaseModel>
83
86
  delete: () => Promise<void>
84
87
  dissociate: (relation: string) => BaseModel
@@ -106,7 +109,7 @@ class BaseModel = {
106
109
  save: () => Promise<BaseModel>
107
110
  toJSON: () => Record<string, unknown>
108
111
  touch: () => Promise<BaseModel>
109
- updatedAt?: Date
112
+ updatedAt?: Carbon
110
113
  }
111
114
 
112
115
  class Blueprint = {
@@ -392,7 +395,7 @@ class Model = {
392
395
  $dirty: () => Record<string, unknown>
393
396
  append: (...keys: string[]) => BaseModel
394
397
  associate: (relation: string, model: BaseModel) => BaseModel
395
- createdAt?: Date
398
+ createdAt?: Carbon
396
399
  decrement: (column: 'id' | 'createdAt' | 'updatedAt' | '__isZerotalModel' | 'fill' | 'forceFill' | 'save' | 'delete' | 'load' | 'loadMissing' | 'fresh' | 'refresh' | 'replicate' | 'touch' | 'is' | 'isNot' | 'increment' | 'decrement' | 'loadCount' | 'loadSum' | 'loadAvg' | 'loadMin' | 'loadMax' | 'makeHidden' | 'makeVisible' | 'append' | 'associate' | 'dissociate' | 'isDirty' | '$dirty' | 'markDirty' | 'toJSON', amount?: number) => Promise<BaseModel>
397
400
  delete: () => Promise<void>
398
401
  dissociate: (relation: string) => BaseModel
@@ -420,7 +423,7 @@ class Model = {
420
423
  save: () => Promise<BaseModel>
421
424
  toJSON: () => Record<string, unknown>
422
425
  touch: () => Promise<BaseModel>
423
- updatedAt?: Date
426
+ updatedAt?: Carbon
424
427
  }
425
428
 
426
429
  class ModelChanged = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.14.3",
3
+ "version": "1.15.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -31,8 +31,8 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.14.3",
35
- "@zerotal/validator": "1.14.3"
34
+ "@zerotal/core": "1.15.1",
35
+ "@zerotal/validator": "1.15.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
@@ -1,6 +1,6 @@
1
1
  import type { SQLInstance } from "../db/sql-types.ts";
2
2
  import type { PaginateResult } from "../db/types.ts";
3
- import { RequestContext, FrameworkEvents } from "@zerotal/core";
3
+ import { RequestContext, FrameworkEvents, ZerotalError } from "@zerotal/core";
4
4
  import { ModelChanged } from "../events.ts";
5
5
  import { Carbon } from "@zerotal/core/carbon";
6
6
  import { toCamelKey as toCamel, toSnakeColumn as toSnake } from "../support/identifiers.ts";
@@ -923,6 +923,10 @@ export class BaseModel {
923
923
  * Primary-key value. Populated after {@link save} inserts a new row, or when
924
924
  * the instance is hydrated from the database.
925
925
  *
926
+ * A model that overrides {@link primaryKey} carries its key under that name
927
+ * instead (`uuid`, `user_id`, …) and leaves this one unset — every persistence
928
+ * method reads the declared key, not this property.
929
+ *
926
930
  * @category Attributes & mass assignment
927
931
  */
928
932
  id!: number;
@@ -930,16 +934,75 @@ export class BaseModel {
930
934
  /**
931
935
  * Creation timestamp, set on insert when {@link timestamps} is enabled.
932
936
  *
937
+ * A {@link Carbon}, like every other `datetime` column — not a `Date`. These
938
+ * two were declared as `Date` while hydration produced a `Carbon`, so
939
+ * `order.createdAt.getTime()` type-checked and threw, and `.slice(0, 10)` on
940
+ * a value assumed to be an ISO string threw differently. Neither has a column
941
+ * declaration in the model to read the truth off, which is exactly why the
942
+ * declaration here has to be right.
943
+ *
944
+ * Use `.toISOString()` for a string, `.valueOf()` for epoch milliseconds, and
945
+ * `.toDate()` when something really needs a native `Date`.
946
+ *
933
947
  * @category Timestamps
934
948
  */
935
- createdAt?: Date;
949
+ createdAt?: Carbon;
936
950
 
937
951
  /**
938
952
  * Last-update timestamp, bumped on every save when {@link timestamps} is enabled.
939
953
  *
954
+ * A {@link Carbon} — see {@link createdAt}. It used to depend on how you got
955
+ * here: a `Carbon` on a freshly loaded row, a raw `Date` on one you had just
956
+ * saved, from the same property on the same model.
957
+ *
940
958
  * @category Timestamps
941
959
  */
942
- updatedAt?: Date;
960
+ updatedAt?: Carbon;
961
+
962
+ /**
963
+ * The value every `WHERE <primary key> = …` this instance issues is keyed on:
964
+ * update, delete, refresh, fresh, increment/decrement and the relation
965
+ * aggregate loads.
966
+ *
967
+ * Reads the property named by {@link primaryKey} rather than `id`, because a
968
+ * model that overrides the key has no `id` — these all used `this.id`, so an
969
+ * `uuid`-keyed model issued `WHERE uuid = NULL`: an update and a delete both
970
+ * reported success having touched no row, and `refresh()` threw
971
+ * {@link ModelNotFoundError} for a row that was sitting right there.
972
+ *
973
+ * Prefers the loaded snapshot so reassigning the key updates the row it was
974
+ * read from (`SET uuid = new WHERE uuid = old`) instead of a row that does not
975
+ * exist yet.
976
+ *
977
+ * @internal
978
+ */
979
+ private get _keyValue(): unknown {
980
+ const pk = toCamel((this.constructor as typeof BaseModel).primaryKey);
981
+ const loaded = (this._original as Record<string, unknown>)[pk];
982
+ return loaded !== undefined ? loaded : (this as unknown as Record<string, unknown>)[pk];
983
+ }
984
+
985
+ /**
986
+ * Throw rather than build a `WHERE <key> = NULL` that quietly matches nothing.
987
+ * Reached when a row was hydrated without its key column (`select("name")`),
988
+ * where the silent version of this looked exactly like a successful write.
989
+ *
990
+ * @internal
991
+ */
992
+ private _requireKey(operation: string): unknown {
993
+ const key = this._keyValue;
994
+ if (key === undefined || key === null) {
995
+ const ModelClass = this.constructor as typeof BaseModel;
996
+ throw new ZerotalError(
997
+ `Cannot ${operation} ${ModelClass.name}: its primary key "${ModelClass.primaryKey}" is not set. ` +
998
+ `Load the row including its key column, or set the key before saving.`,
999
+ "E_NO_PRIMARY_KEY",
1000
+ 500,
1001
+ { model: ModelClass.name, primaryKey: ModelClass.primaryKey, operation },
1002
+ );
1003
+ }
1004
+ return key;
1005
+ }
943
1006
 
944
1007
  /** @internal Snapshot of attribute values at load/last-save, for dirty tracking. */
945
1008
  private _original: Record<string, unknown> = {};
@@ -1748,13 +1811,25 @@ export class BaseModel {
1748
1811
  const colReg = columnsFor(ModelClass as unknown as ClassRef);
1749
1812
  const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
1750
1813
 
1814
+ const pkProp = toCamel(ModelClass.primaryKey);
1815
+ const pkColumn = toSnake(ModelClass.primaryKey);
1816
+
1751
1817
  if (!this._exists) {
1752
1818
  // ── INSERT ──
1753
1819
  await HookRegistry.run(ModelClass, "beforeCreate", this);
1754
1820
 
1821
+ // The primary key is a system column only while the DATABASE mints it. When the
1822
+ // app mints it — `static primaryKey = "uuid"`, or a TEXT `id` — it has to reach
1823
+ // the INSERT, so drop it from the skip set here (it is added back explicitly
1824
+ // below, since the key is often not `@column`-registered and `colKeys` would
1825
+ // filter it out). An unset key is still omitted, so AUTOINCREMENT is untouched.
1826
+ const insertSkip = SYSTEM_KEYS.has(pkProp)
1827
+ ? new Set([...SYSTEM_KEYS].filter((k) => k !== pkProp))
1828
+ : SYSTEM_KEYS;
1829
+
1755
1830
  const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1756
1831
  const r: Record<string, unknown> = {};
1757
- for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
1832
+ for (const [key, val] of ownDataEntries(this, insertSkip, rels, colKeys)) {
1758
1833
  // A declared field that was never assigned is `undefined`, and writing that
1759
1834
  // as an explicit NULL made `@column({ default: … })` inert: the INSERT named
1760
1835
  // the column, so the database never applied its own default and a NOT NULL
@@ -1774,6 +1849,12 @@ export class BaseModel {
1774
1849
  }
1775
1850
  r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg, ModelClass.name);
1776
1851
  }
1852
+ // An app-minted key that is not `@column`-registered (the common shape for a
1853
+ // plain `id`) never reaches the loop above, so take it straight off the instance.
1854
+ const minted = self[pkProp];
1855
+ if (minted !== undefined && minted !== null && !(pkColumn in r)) {
1856
+ r[pkColumn] = _serializeForWrite(pkProp, minted, casts, colReg, ModelClass.name);
1857
+ }
1777
1858
  if (ModelClass.timestamps) {
1778
1859
  const now = _serializeDate(new Date());
1779
1860
  r["created_at"] = now;
@@ -1792,11 +1873,22 @@ export class BaseModel {
1792
1873
  });
1793
1874
  segs.push(")");
1794
1875
 
1795
- let newId: number;
1796
- if (dialect === "postgres") {
1876
+ // The key to read the row back by. When the app minted it there is nothing to
1877
+ // ask the database for — and asking was the bug: `last_insert_rowid()` /
1878
+ // `LAST_INSERT_ID()` answer with a rowid, which no TEXT key ever equals, so the
1879
+ // re-read below matched no row and left the instance with no timestamps, no
1880
+ // database defaults and `_exists` still false — making the NEXT save() insert
1881
+ // the same row a second time instead of updating it.
1882
+ const mintedKey = pkColumn in row ? row[pkColumn] : undefined;
1883
+
1884
+ let newId: unknown;
1885
+ if (mintedKey !== undefined && mintedKey !== null) {
1886
+ await runSegs(conn, segs);
1887
+ newId = mintedKey;
1888
+ } else if (dialect === "postgres") {
1797
1889
  segs.push(` RETURNING ${ModelClass.primaryKey}`);
1798
- const [returning] = await runQuery<Record<string, number>>(conn, segs);
1799
- newId = returning![ModelClass.primaryKey as string] as number;
1890
+ const [returning] = await runQuery<Record<string, unknown>>(conn, segs);
1891
+ newId = returning![ModelClass.primaryKey as string];
1800
1892
  } else if (dialect === "mysql") {
1801
1893
  // LAST_INSERT_ID() is scoped per connection, so the INSERT and the
1802
1894
  // SELECT must be pinned to the SAME connection — under concurrency a
@@ -1819,15 +1911,33 @@ export class BaseModel {
1819
1911
  };
1820
1912
  if (inTx) {
1821
1913
  newId = await insertAndReadId(conn);
1822
- } else if (typeof reservable.reserve === "function") {
1823
- const pinned = await reservable.reserve();
1824
- try {
1825
- newId = await insertAndReadId(pinned);
1826
- } finally {
1827
- pinned.release();
1828
- }
1829
1914
  } else {
1830
- newId = await conn.begin((tx) => insertAndReadId(tx));
1915
+ // Presence is not support. Bun's MySQL adapter now exposes `reserve()`
1916
+ // and throws "This adapter doesn't support connection reservation" when
1917
+ // it is called, so `typeof … === "function"` stopped distinguishing the
1918
+ // two and every MySQL insert failed on a runtime that had merely been
1919
+ // updated. Only the acquisition is guarded — a failure inside the insert
1920
+ // is a real error and must not be retried against a second connection.
1921
+ let pinned: (SQLInstance & { release(): void }) | undefined;
1922
+ if (typeof reservable.reserve === "function") {
1923
+ try {
1924
+ // try/catch rather than `.catch()`: an adapter that refuses may throw
1925
+ // synchronously, before there is a promise to attach a handler to.
1926
+ pinned = await reservable.reserve();
1927
+ } catch {
1928
+ pinned = undefined;
1929
+ }
1930
+ }
1931
+
1932
+ if (pinned) {
1933
+ try {
1934
+ newId = await insertAndReadId(pinned);
1935
+ } finally {
1936
+ pinned.release();
1937
+ }
1938
+ } else {
1939
+ newId = await conn.begin((tx) => insertAndReadId(tx));
1940
+ }
1831
1941
  }
1832
1942
  } else {
1833
1943
  await runSegs(conn, segs);
@@ -1841,7 +1951,17 @@ export class BaseModel {
1841
1951
  `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1842
1952
  { val: newId },
1843
1953
  ]);
1844
- if (rows[0]) _applyRow(this, rows[0]);
1954
+ if (rows[0]) {
1955
+ _applyRow(this, rows[0]);
1956
+ } else {
1957
+ // The INSERT succeeded, so the instance is resident whether or not the row came
1958
+ // back (a trigger may have moved it, the target may be a view). Record that much
1959
+ // instead of leaving `_exists` false, which turns the next save() into a second
1960
+ // INSERT and a duplicate-key error rather than the UPDATE the caller asked for.
1961
+ if (newId !== undefined && newId !== null) self[pkProp] = newId;
1962
+ this._exists = true;
1963
+ this._original = { ...this._original, [pkProp]: self[pkProp] };
1964
+ }
1845
1965
 
1846
1966
  await HookRegistry.run(ModelClass, "afterCreate", this);
1847
1967
  } else {
@@ -1849,7 +1969,10 @@ export class BaseModel {
1849
1969
  await HookRegistry.run(ModelClass, "beforeUpdate", this);
1850
1970
 
1851
1971
  const dirty = this.$dirty();
1852
- if (ModelClass.timestamps) dirty["updatedAt"] = new Date();
1972
+ // Carbon, not `new Date()`. The entries below are written back onto the
1973
+ // instance, so a plain Date here left `updatedAt` holding a different class
1974
+ // after a save than after a load — with the declared type matching neither.
1975
+ if (ModelClass.timestamps) dirty["updatedAt"] = Carbon.now();
1853
1976
 
1854
1977
  if (Object.keys(dirty).length > 0) {
1855
1978
  const entries = _writeDialect.run(dialect, () =>
@@ -1869,7 +1992,7 @@ export class BaseModel {
1869
1992
  segs.push({ val });
1870
1993
  });
1871
1994
  segs.push(` WHERE ${ModelClass.primaryKey} = `);
1872
- segs.push({ val: this.id });
1995
+ segs.push({ val: this._requireKey("update") });
1873
1996
  await runSegs(conn, segs);
1874
1997
 
1875
1998
  const self = this as unknown as Record<string, unknown>;
@@ -1900,20 +2023,21 @@ export class BaseModel {
1900
2023
  await HookRegistry.run(ModelClass, "beforeDelete", this);
1901
2024
 
1902
2025
  if (ModelClass.softDeletes) {
1903
- const now = new Date();
2026
+ const now = Carbon.now();
1904
2027
  await runSegs(conn, [
1905
2028
  `UPDATE ${ModelClass.table} SET deleted_at = `,
1906
- { val: _writeDialect.run(dialect, () => _serializeDate(now)) },
2029
+ { val: _writeDialect.run(dialect, () => _serializeDate(now.toDate())) },
1907
2030
  ` WHERE ${ModelClass.primaryKey} = `,
1908
- { val: this.id },
2031
+ { val: this._requireKey("delete") },
1909
2032
  ]);
1910
2033
  // `deletedAt` lives on the SoftDeletes mixin; this branch only runs for models
1911
- // that compose it (softDeletes === true).
1912
- (this as { deletedAt?: Date | null }).deletedAt = now;
2034
+ // that compose it (softDeletes === true). Carbon, to match what a reload of
2035
+ // this same row would put there.
2036
+ (this as { deletedAt?: Carbon | null }).deletedAt = now;
1913
2037
  } else {
1914
2038
  await runSegs(conn, [
1915
2039
  `DELETE FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1916
- { val: this.id },
2040
+ { val: this._requireKey("delete") },
1917
2041
  ]);
1918
2042
  }
1919
2043
 
@@ -1975,11 +2099,12 @@ export class BaseModel {
1975
2099
  async fresh(): Promise<this> {
1976
2100
  const ModelClass = this.constructor as typeof BaseModel;
1977
2101
  const conn = _resolveConn(ModelClass);
2102
+ const key = this._requireKey("reload");
1978
2103
  const rows = await runQuery<Record<string, unknown>>(conn, [
1979
2104
  `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1980
- { val: this.id },
2105
+ { val: key },
1981
2106
  ]);
1982
- if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, this.id);
2107
+ if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, key as number | string);
1983
2108
  return ModelClass.fromRow(rows[0]) as this;
1984
2109
  }
1985
2110
 
@@ -1993,11 +2118,12 @@ export class BaseModel {
1993
2118
  async refresh(): Promise<this> {
1994
2119
  const ModelClass = this.constructor as typeof BaseModel;
1995
2120
  const conn = _resolveConn(ModelClass);
2121
+ const key = this._requireKey("reload");
1996
2122
  const rows = await runQuery<Record<string, unknown>>(conn, [
1997
2123
  `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1998
- { val: this.id },
2124
+ { val: key },
1999
2125
  ]);
2000
- if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, this.id);
2126
+ if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, key as number | string);
2001
2127
  _applyRow(this, rows[0]);
2002
2128
  return this;
2003
2129
  }
@@ -2012,7 +2138,13 @@ export class BaseModel {
2012
2138
  const ModelClass = this.constructor as typeof BaseModel;
2013
2139
  const rels = relNames(ModelClass as unknown as ClassRef);
2014
2140
  const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
2015
- const skip = new Set<string>([...SYSTEM_KEYS, ...(except ?? [])]);
2141
+ // The declared key, not just `id` — copying a `uuid` forward produced a replica
2142
+ // that could only ever fail on save with a duplicate-key error.
2143
+ const skip = new Set<string>([
2144
+ ...SYSTEM_KEYS,
2145
+ toCamel(ModelClass.primaryKey),
2146
+ ...(except ?? []),
2147
+ ]);
2016
2148
  const inst = new (this.constructor as new () => this)();
2017
2149
  for (const [k, v] of ownDataEntries(this, skip, rels, colKeys)) {
2018
2150
  (inst as unknown as Record<string, unknown>)[k] = v;
@@ -2028,7 +2160,7 @@ export class BaseModel {
2028
2160
  async touch(): Promise<this> {
2029
2161
  const ModelClass = this.constructor as typeof BaseModel;
2030
2162
  if (!ModelClass.timestamps) return this;
2031
- this.updatedAt = new Date();
2163
+ this.updatedAt = Carbon.now();
2032
2164
  this.markDirty("updatedAt" as keyof this);
2033
2165
  return this.save();
2034
2166
  }
@@ -2086,7 +2218,7 @@ export class BaseModel {
2086
2218
  `UPDATE ${ModelClass.table} SET ${col} = ${col} ${op} `,
2087
2219
  { val: Math.abs(delta) },
2088
2220
  ` WHERE ${ModelClass.primaryKey} = `,
2089
- { val: this.id },
2221
+ { val: this._requireKey(delta >= 0 ? "increment" : "decrement") },
2090
2222
  ]);
2091
2223
  const self = this as unknown as Record<string, unknown>;
2092
2224
  const current = Number(self[column] ?? 0);
@@ -2104,7 +2236,7 @@ export class BaseModel {
2104
2236
  const rels = Array.isArray(relations) ? relations : [relations];
2105
2237
  const ModelClass = this.constructor as typeof BaseModel;
2106
2238
  const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
2107
- qb.where(ModelClass.primaryKey, this.id);
2239
+ qb.where(ModelClass.primaryKey, this._keyValue as number | string);
2108
2240
  for (const r of rels) qb.withCount(r);
2109
2241
  const fresh = (await qb.limit(1).get<BaseModel>())[0] ?? null;
2110
2242
  const src = fresh as unknown as Record<string, unknown> | null;
@@ -2135,15 +2267,18 @@ export class BaseModel {
2135
2267
  ): Promise<T[]> {
2136
2268
  if (models.length === 0) return models;
2137
2269
  const rels = Array.isArray(relations) ? relations : [relations];
2138
- const idOf = (m: T): unknown => (m as unknown as { id: unknown }).id;
2270
+ // Read the declared key, not `id`: on a model that overrides `primaryKey` this
2271
+ // collected a list of `undefined` and set every count to 0.
2272
+ const keyProp = toCamel(this.primaryKey);
2273
+ const idOf = (m: BaseModel): unknown => (m as unknown as Record<string, unknown>)[keyProp];
2139
2274
 
2140
2275
  const qb = new ModelQueryBuilder(this.table, _resolveConn(this), this);
2141
- qb.whereIn(this.primaryKey, models.map(idOf));
2276
+ qb.whereIn(this.primaryKey, models.map(idOf) as (number | string)[]);
2142
2277
  for (const r of rels) qb.withCount(r);
2143
2278
  const fresh = await qb.get<BaseModel>();
2144
2279
 
2145
2280
  const byId = new Map<unknown, Record<string, unknown>>();
2146
- for (const f of fresh) byId.set((f as unknown as { id: unknown }).id, f as never);
2281
+ for (const f of fresh) byId.set(idOf(f), f as never);
2147
2282
 
2148
2283
  for (const m of models) {
2149
2284
  const src = byId.get(idOf(m));
@@ -2164,7 +2299,7 @@ export class BaseModel {
2164
2299
  ): Promise<this> {
2165
2300
  const ModelClass = this.constructor as typeof BaseModel;
2166
2301
  const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
2167
- qb.where(ModelClass.primaryKey, this.id);
2302
+ qb.where(ModelClass.primaryKey, this._keyValue as number | string);
2168
2303
  (qb as unknown as Record<string, (r: string, c: string) => void>)[`with${fn}`]?.(
2169
2304
  relation,
2170
2305
  column,
@@ -24,6 +24,7 @@ import { _resolveConn, type BaseModel } from "./BaseModel.ts";
24
24
  import { ModelQueryBuilder } from "./ModelQueryBuilder.ts";
25
25
  import { QueryBuilder } from "../db/QueryBuilder.ts";
26
26
  import type { Constructor } from "./mixins.ts";
27
+ import type { Carbon } from "@zerotal/core/carbon";
27
28
 
28
29
  // Structural view of the concrete model class used by the static scopes.
29
30
  interface SoftDeleteModelClass<T extends BaseModel> {
@@ -63,8 +64,13 @@ export function SoftDeletes<TBase extends Constructor>(Base: TBase) {
63
64
  /** Engine switch — query scoping + schema `deleted_at` provisioning read this. */
64
65
  static softDeletes = true;
65
66
 
66
- /** When the row was soft-deleted, or null/undefined when live. */
67
- declare deletedAt?: Date | null;
67
+ /**
68
+ * When the row was soft-deleted, or null/undefined when live.
69
+ *
70
+ * A {@link Carbon}, like `createdAt`/`updatedAt` and every other `datetime`
71
+ * column — `.toISOString()` for a string, `.toDate()` for a native `Date`.
72
+ */
73
+ declare deletedAt?: Carbon | null;
68
74
 
69
75
  /** A query that INCLUDES soft-deleted rows (bypasses the default scope). */
70
76
  static withTrashed<T extends BaseModel>(this: SoftDeleteModelClass<T>): ModelQueryBuilder<T> {