@zerotal/orm 1.15.0 → 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,69 @@ 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
+
11
74
  ## [1.15.0] — 2026-09-04
12
75
 
13
76
  ### Changed — **BREAKING**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.15.0",
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.15.0",
35
- "@zerotal/validator": "1.15.0"
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;
@@ -955,6 +959,51 @@ export class BaseModel {
955
959
  */
956
960
  updatedAt?: Carbon;
957
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
+ }
1006
+
958
1007
  /** @internal Snapshot of attribute values at load/last-save, for dirty tracking. */
959
1008
  private _original: Record<string, unknown> = {};
960
1009
  /** @internal Keys force-marked dirty via {@link markDirty}. */
@@ -1762,13 +1811,25 @@ export class BaseModel {
1762
1811
  const colReg = columnsFor(ModelClass as unknown as ClassRef);
1763
1812
  const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
1764
1813
 
1814
+ const pkProp = toCamel(ModelClass.primaryKey);
1815
+ const pkColumn = toSnake(ModelClass.primaryKey);
1816
+
1765
1817
  if (!this._exists) {
1766
1818
  // ── INSERT ──
1767
1819
  await HookRegistry.run(ModelClass, "beforeCreate", this);
1768
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
+
1769
1830
  const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1770
1831
  const r: Record<string, unknown> = {};
1771
- for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
1832
+ for (const [key, val] of ownDataEntries(this, insertSkip, rels, colKeys)) {
1772
1833
  // A declared field that was never assigned is `undefined`, and writing that
1773
1834
  // as an explicit NULL made `@column({ default: … })` inert: the INSERT named
1774
1835
  // the column, so the database never applied its own default and a NOT NULL
@@ -1788,6 +1849,12 @@ export class BaseModel {
1788
1849
  }
1789
1850
  r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg, ModelClass.name);
1790
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
+ }
1791
1858
  if (ModelClass.timestamps) {
1792
1859
  const now = _serializeDate(new Date());
1793
1860
  r["created_at"] = now;
@@ -1806,11 +1873,22 @@ export class BaseModel {
1806
1873
  });
1807
1874
  segs.push(")");
1808
1875
 
1809
- let newId: number;
1810
- 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") {
1811
1889
  segs.push(` RETURNING ${ModelClass.primaryKey}`);
1812
- const [returning] = await runQuery<Record<string, number>>(conn, segs);
1813
- 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];
1814
1892
  } else if (dialect === "mysql") {
1815
1893
  // LAST_INSERT_ID() is scoped per connection, so the INSERT and the
1816
1894
  // SELECT must be pinned to the SAME connection — under concurrency a
@@ -1833,15 +1911,33 @@ export class BaseModel {
1833
1911
  };
1834
1912
  if (inTx) {
1835
1913
  newId = await insertAndReadId(conn);
1836
- } else if (typeof reservable.reserve === "function") {
1837
- const pinned = await reservable.reserve();
1838
- try {
1839
- newId = await insertAndReadId(pinned);
1840
- } finally {
1841
- pinned.release();
1842
- }
1843
1914
  } else {
1844
- 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
+ }
1845
1941
  }
1846
1942
  } else {
1847
1943
  await runSegs(conn, segs);
@@ -1855,7 +1951,17 @@ export class BaseModel {
1855
1951
  `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1856
1952
  { val: newId },
1857
1953
  ]);
1858
- 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
+ }
1859
1965
 
1860
1966
  await HookRegistry.run(ModelClass, "afterCreate", this);
1861
1967
  } else {
@@ -1886,7 +1992,7 @@ export class BaseModel {
1886
1992
  segs.push({ val });
1887
1993
  });
1888
1994
  segs.push(` WHERE ${ModelClass.primaryKey} = `);
1889
- segs.push({ val: this.id });
1995
+ segs.push({ val: this._requireKey("update") });
1890
1996
  await runSegs(conn, segs);
1891
1997
 
1892
1998
  const self = this as unknown as Record<string, unknown>;
@@ -1922,7 +2028,7 @@ export class BaseModel {
1922
2028
  `UPDATE ${ModelClass.table} SET deleted_at = `,
1923
2029
  { val: _writeDialect.run(dialect, () => _serializeDate(now.toDate())) },
1924
2030
  ` WHERE ${ModelClass.primaryKey} = `,
1925
- { val: this.id },
2031
+ { val: this._requireKey("delete") },
1926
2032
  ]);
1927
2033
  // `deletedAt` lives on the SoftDeletes mixin; this branch only runs for models
1928
2034
  // that compose it (softDeletes === true). Carbon, to match what a reload of
@@ -1931,7 +2037,7 @@ export class BaseModel {
1931
2037
  } else {
1932
2038
  await runSegs(conn, [
1933
2039
  `DELETE FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1934
- { val: this.id },
2040
+ { val: this._requireKey("delete") },
1935
2041
  ]);
1936
2042
  }
1937
2043
 
@@ -1993,11 +2099,12 @@ export class BaseModel {
1993
2099
  async fresh(): Promise<this> {
1994
2100
  const ModelClass = this.constructor as typeof BaseModel;
1995
2101
  const conn = _resolveConn(ModelClass);
2102
+ const key = this._requireKey("reload");
1996
2103
  const rows = await runQuery<Record<string, unknown>>(conn, [
1997
2104
  `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1998
- { val: this.id },
2105
+ { val: key },
1999
2106
  ]);
2000
- if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, this.id);
2107
+ if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, key as number | string);
2001
2108
  return ModelClass.fromRow(rows[0]) as this;
2002
2109
  }
2003
2110
 
@@ -2011,11 +2118,12 @@ export class BaseModel {
2011
2118
  async refresh(): Promise<this> {
2012
2119
  const ModelClass = this.constructor as typeof BaseModel;
2013
2120
  const conn = _resolveConn(ModelClass);
2121
+ const key = this._requireKey("reload");
2014
2122
  const rows = await runQuery<Record<string, unknown>>(conn, [
2015
2123
  `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
2016
- { val: this.id },
2124
+ { val: key },
2017
2125
  ]);
2018
- if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, this.id);
2126
+ if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, key as number | string);
2019
2127
  _applyRow(this, rows[0]);
2020
2128
  return this;
2021
2129
  }
@@ -2030,7 +2138,13 @@ export class BaseModel {
2030
2138
  const ModelClass = this.constructor as typeof BaseModel;
2031
2139
  const rels = relNames(ModelClass as unknown as ClassRef);
2032
2140
  const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
2033
- 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
+ ]);
2034
2148
  const inst = new (this.constructor as new () => this)();
2035
2149
  for (const [k, v] of ownDataEntries(this, skip, rels, colKeys)) {
2036
2150
  (inst as unknown as Record<string, unknown>)[k] = v;
@@ -2104,7 +2218,7 @@ export class BaseModel {
2104
2218
  `UPDATE ${ModelClass.table} SET ${col} = ${col} ${op} `,
2105
2219
  { val: Math.abs(delta) },
2106
2220
  ` WHERE ${ModelClass.primaryKey} = `,
2107
- { val: this.id },
2221
+ { val: this._requireKey(delta >= 0 ? "increment" : "decrement") },
2108
2222
  ]);
2109
2223
  const self = this as unknown as Record<string, unknown>;
2110
2224
  const current = Number(self[column] ?? 0);
@@ -2122,7 +2236,7 @@ export class BaseModel {
2122
2236
  const rels = Array.isArray(relations) ? relations : [relations];
2123
2237
  const ModelClass = this.constructor as typeof BaseModel;
2124
2238
  const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
2125
- qb.where(ModelClass.primaryKey, this.id);
2239
+ qb.where(ModelClass.primaryKey, this._keyValue as number | string);
2126
2240
  for (const r of rels) qb.withCount(r);
2127
2241
  const fresh = (await qb.limit(1).get<BaseModel>())[0] ?? null;
2128
2242
  const src = fresh as unknown as Record<string, unknown> | null;
@@ -2153,15 +2267,18 @@ export class BaseModel {
2153
2267
  ): Promise<T[]> {
2154
2268
  if (models.length === 0) return models;
2155
2269
  const rels = Array.isArray(relations) ? relations : [relations];
2156
- 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];
2157
2274
 
2158
2275
  const qb = new ModelQueryBuilder(this.table, _resolveConn(this), this);
2159
- qb.whereIn(this.primaryKey, models.map(idOf));
2276
+ qb.whereIn(this.primaryKey, models.map(idOf) as (number | string)[]);
2160
2277
  for (const r of rels) qb.withCount(r);
2161
2278
  const fresh = await qb.get<BaseModel>();
2162
2279
 
2163
2280
  const byId = new Map<unknown, Record<string, unknown>>();
2164
- 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);
2165
2282
 
2166
2283
  for (const m of models) {
2167
2284
  const src = byId.get(idOf(m));
@@ -2182,7 +2299,7 @@ export class BaseModel {
2182
2299
  ): Promise<this> {
2183
2300
  const ModelClass = this.constructor as typeof BaseModel;
2184
2301
  const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
2185
- qb.where(ModelClass.primaryKey, this.id);
2302
+ qb.where(ModelClass.primaryKey, this._keyValue as number | string);
2186
2303
  (qb as unknown as Record<string, (r: string, c: string) => void>)[`with${fn}`]?.(
2187
2304
  relation,
2188
2305
  column,