@zerotal/orm 1.15.0 → 1.15.2

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,88 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.2] — 2026-10-23
12
+
13
+ ### Fixed
14
+
15
+ - **The ORM runs on Bun 1.4.4.** Every statement it assembled from a runtime string — the
16
+ string form of `DB.raw()`, the query builder, schema and migration DDL, the `SAVEPOINT`
17
+ a nested transaction issues, the validator's `unique` and `exists` rules — reached Bun's
18
+ `sql` function as a template object built with `Object.assign(parts, { raw: parts })`,
19
+ whose `raw` is enumerable. Bun 1.4.4 refuses that ("has an enumerable 'raw' property, so
20
+ it is not a template object"), so on that version and later none of those statements ran:
21
+ the package's own suite failed 288 tests and then hung. Earlier Bun accepted it, which is
22
+ why nothing noticed until the CI job that runs on the latest Bun did.
23
+
24
+ All of them now take the object from one helper, `_templateStrings()`, which defines
25
+ `raw` the way a real template object has it and freezes the result. `@zerotal/notifications`
26
+ and `@zerotal/testing` had built the same object by hand and use it too. One test runs the
27
+ ORM's paths through a connection that rejects an enumerable `raw`; another fails if any
28
+ package's source builds one again.
29
+
30
+ ## [1.15.1] — 2026-09-13
31
+
32
+ ### Fixed
33
+
34
+ - **A model with a primary key other than `id` could not be updated, deleted or
35
+ reloaded.** `static primaryKey = "uuid"` (and the `@(table("…").primaryKey("…"))` chain)
36
+ configured the column name everywhere the key was written into SQL, but the value bound
37
+ against it came from `this.id` — a property such a model never has. Every statement
38
+ therefore went out as `WHERE uuid = NULL`: `save()` on a loaded instance and `delete()`
39
+ each matched zero rows and returned as though they had written, `refresh()` and `fresh()`
40
+ threw `ModelNotFoundError` for a row that was sitting in the table, and `increment()`,
41
+ `decrement()`, `loadCount()` and the relation aggregate loads no-opped or reported zero.
42
+
43
+ The insert was worse than a silent no-op. It wrote the row correctly, then read it back
44
+ by `last_insert_rowid()` / `LAST_INSERT_ID()`, which answer with a rowid that no `TEXT`
45
+ key ever equals — so the re-read matched nothing and the instance came back with no
46
+ timestamps, no database defaults, and still marked as not yet resident. The next `save()`
47
+ on it was a second INSERT and a duplicate-key error rather than the UPDATE the caller
48
+ asked for.
49
+
50
+ Every one of those paths now reads the key by its declared name, preferring the loaded
51
+ value so reassigning a key updates the row it was read from. An insert whose key the app
52
+ minted skips the generated-id round-trip and reads the row back by the key it just wrote.
53
+ `replicate()` drops the declared key instead of only `id`, so the copy is insertable.
54
+ A write against an instance whose key is genuinely absent — a row hydrated by
55
+ `select("title")`, say — now throws `E_NO_PRIMARY_KEY` instead of binding NULL and
56
+ looking like a success.
57
+
58
+ - **A primary key the app mints was stripped from the INSERT.** `id` was treated as
59
+ database-generated unconditionally, so a table whose key is a `TEXT id` the application
60
+ supplies took the value on the instance, dropped it from the statement, and stored NULL
61
+ while the in-memory model went on holding the value it thought it had written. The key is
62
+ now omitted only when it has no value, which is what leaves AUTOINCREMENT to the database.
63
+
64
+ - **Hardening: a MySQL adapter that offers `reserve()` may still refuse it.**
65
+ `LAST_INSERT_ID()` is scoped per connection, so a MySQL insert pins its INSERT and the
66
+ SELECT that reads the id to one connection, reserving one where the adapter supports it
67
+ and falling back to a short transaction where it does not. The check was
68
+ `typeof conn.reserve === "function"`, and presence is not support — an adapter can expose
69
+ the method and throw `"This adapter doesn't support connection reservation"` when it is
70
+ called, which is what Bun's SQLite adapter does.
71
+
72
+ The reservation is now attempted and the transaction path used when it refuses. Only the
73
+ acquisition is guarded: a failure inside the insert is a real error and is not retried
74
+ against a second connection. `try`/`catch` rather than `.catch()`, since an adapter that
75
+ refuses may throw synchronously.
76
+
77
+ No released version was affected — this path was only reachable through a test that had
78
+ leaked a dialect (see below). Changed because the detection was wrong either way.
79
+
80
+ - **A dialect-coverage test left the ORM in MySQL mode for the rest of the process.**
81
+ `server.smoke.test.ts` sets four globals in `beforeAll` and restored three — the
82
+ BaseModel dialect stayed on whatever server it had just tested. Every later file in that
83
+ process then took the MySQL insert branch, which reads the new row's id with
84
+ `LAST_INSERT_ID()`; against the SQLite connection those tests actually use, that is
85
+ `no such function`.
86
+
87
+ Test-only, and it never reached a release: the leak needs `ZT_MYSQL_URL` set, which
88
+ happens in one CI job. Postgres hid it for months — its branch uses `RETURNING`, which
89
+ SQLite also supports — so only the MySQL job could go red, and only when the file order
90
+ put the smoke test first. Bun walks test files in directory order, which is arbitrary on
91
+ Linux, so "when" was a coin flip on every run.
92
+
11
93
  ## [1.15.0] — 2026-09-04
12
94
 
13
95
  ### 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.2",
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.2",
35
+ "@zerotal/validator": "1.15.2"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
package/src/db/DB.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  allowNPlusOne as _allowNPlusOne,
18
18
  type NPlusOneOptions,
19
19
  } from "./NPlusOneDetector.ts";
20
+ import { _templateStrings } from "./templateStrings.ts";
20
21
 
21
22
  // Test-override escape hatch — set by _setDbConnection() in test files.
22
23
  // Production code resolves the connection from the container instead.
@@ -140,7 +141,7 @@ let _savepointCounter = 0;
140
141
 
141
142
  /** Run a raw, parameter-less statement on a specific connection (SAVEPOINT, etc.). */
142
143
  function _runRaw(conn: SQLInstance, sql: string): Promise<unknown> {
143
- const arr = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray;
144
+ const arr = _templateStrings([sql]);
144
145
  return conn(arr);
145
146
  }
146
147
 
@@ -160,7 +161,7 @@ export function _rawStatement(sql: string): Promise<unknown> {
160
161
  /** Run a parameterised statement (`?` placeholders) on a specific connection. */
161
162
  function _runParams(conn: SQLInstance, sql: string, params: unknown[]): Promise<unknown> {
162
163
  const parts = sql.split("?");
163
- const tpl = Object.assign(parts, { raw: parts }) as unknown as TemplateStringsArray;
164
+ const tpl = _templateStrings(parts);
164
165
  return conn(tpl, ...params);
165
166
  }
166
167
 
@@ -246,7 +247,7 @@ export const DB = {
246
247
  if (typeof sql === "string") {
247
248
  const bindings: unknown[] = Array.isArray(rest[0]) ? (rest[0] as unknown[]) : rest;
248
249
  const parts = sql.split("?");
249
- const tpl = Object.assign(parts, { raw: parts }) as unknown as TemplateStringsArray;
250
+ const tpl = _templateStrings(parts);
250
251
  const rows = await conn<T>(tpl, ...bindings);
251
252
  FrameworkEvents.emit(
252
253
  new QueryExecuted(
@@ -20,6 +20,7 @@ import type {
20
20
  SimplePaginateResult,
21
21
  } from "./types.ts";
22
22
  import { withPaginationHelpers, withSimplePaginationHelpers } from "./types.ts";
23
+ import { _templateStrings } from "./templateStrings.ts";
23
24
 
24
25
  /**
25
26
  * The comparison operators accepted in a value-comparison slot. Exported so
@@ -204,8 +205,7 @@ function _getCachedTemplate(strings: string[]): TemplateStringsArray {
204
205
  const key = strings.join("\x00");
205
206
  let tpl = _tplCache.get(key);
206
207
  if (!tpl) {
207
- const arr = [...strings];
208
- tpl = Object.assign(arr, { raw: arr }) as TemplateStringsArray;
208
+ tpl = _templateStrings(strings);
209
209
  if (_tplCache.size >= _TPL_CACHE_MAX) {
210
210
  // FIFO eviction: drop the oldest interned shape.
211
211
  const oldest = _tplCache.keys().next().value;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Build the `strings` argument for a Bun SQL tagged-template call from plain strings.
3
+ *
4
+ * Bun's `sql` function wants what the language hands a tag: a frozen array whose `raw`
5
+ * is a property you cannot see when you enumerate it. `Object.assign(parts, { raw: parts })`
6
+ * makes the property enumerable, and Bun 1.4.4 refuses that with "has an enumerable 'raw'
7
+ * property, so it is not a template object" — every statement built from a runtime-assembled
8
+ * string failed, which is nearly everything the ORM sends. `defineProperty` leaves `raw`
9
+ * non-enumerable, as a real template object has it.
10
+ *
11
+ * Copies its input, so the caller's array is neither frozen nor given a `raw`.
12
+ *
13
+ * @internal Shared by the ORM and by the packages that issue SQL of their own.
14
+ */
15
+ export function _templateStrings(parts: readonly string[]): TemplateStringsArray {
16
+ const strings = [...parts];
17
+ Object.defineProperty(strings, "raw", { value: strings });
18
+ return Object.freeze(strings) as TemplateStringsArray;
19
+ }
package/src/index.ts CHANGED
@@ -97,6 +97,7 @@ export type { InsertPayload, UpdatePayload } from "./model/payload.ts";
97
97
  export { ModelQueryBuilder, _globalScopeRegistry } from "./model/ModelQueryBuilder.ts";
98
98
  export type { GlobalScopeCallback, RelationConstraint } from "./model/ModelQueryBuilder.ts";
99
99
  export { DB } from "./db/DB.ts";
100
+ export { _templateStrings } from "./db/templateStrings.ts";
100
101
  export {
101
102
  _getConnection,
102
103
  _setDbConnection,
@@ -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,
@@ -25,6 +25,7 @@ import {
25
25
  RUN_MIGRATIONS_PATH,
26
26
  _mintDiagnosisToken,
27
27
  } from "../diagnostics/runMigrationsEndpoint.ts";
28
+ import { _templateStrings } from "../db/templateStrings.ts";
28
29
 
29
30
  // Extend the core container registry so 'db' is a typed binding.
30
31
  declare module "@zerotal/core" {
@@ -171,27 +172,17 @@ export class DatabaseProvider extends ServiceProvider {
171
172
  let rows: unknown[];
172
173
  if (ignoreId !== undefined) {
173
174
  const strs = [`SELECT 1 FROM ${table} WHERE ${column} = `, ` AND id != `, ` LIMIT 1`];
174
- rows = await conn(
175
- Object.assign(strs, { raw: strs }) as unknown as TemplateStringsArray,
176
- value,
177
- ignoreId,
178
- );
175
+ rows = await conn(_templateStrings(strs), value, ignoreId);
179
176
  } else {
180
177
  const strs = [`SELECT 1 FROM ${table} WHERE ${column} = `, ` LIMIT 1`];
181
- rows = await conn(
182
- Object.assign(strs, { raw: strs }) as unknown as TemplateStringsArray,
183
- value,
184
- );
178
+ rows = await conn(_templateStrings(strs), value);
185
179
  }
186
180
  return rows.length === 0; // true = unique (no duplicate found)
187
181
  }
188
182
 
189
183
  // exists
190
184
  const strs = [`SELECT 1 FROM ${table} WHERE ${column} = `, ` LIMIT 1`];
191
- const rows = await conn(
192
- Object.assign(strs, { raw: strs }) as unknown as TemplateStringsArray,
193
- value,
194
- );
185
+ const rows = await conn(_templateStrings(strs), value);
195
186
  return rows.length > 0; // true = exists (row found)
196
187
  });
197
188
  } catch {
@@ -8,6 +8,7 @@ import { MigrationRan } from "../events.ts";
8
8
  import { dialectFor } from "../db/QueryBuilder.ts";
9
9
  import { getDialect } from "../db/dialects/index.ts";
10
10
  import { TransactionContext } from "../db/TransactionContext.ts";
11
+ import { _templateStrings } from "../db/templateStrings.ts";
11
12
 
12
13
  // ── Public types ──────────────────────────────────────────────────────────────
13
14
 
@@ -375,9 +376,7 @@ export class MigrationRunner {
375
376
  /** Execute a DDL string with no parameters. */
376
377
  private async _ddl(sql: string): Promise<void> {
377
378
  const strings = [sql];
378
- const tpl = Object.assign(strings, {
379
- raw: strings,
380
- }) as TemplateStringsArray;
379
+ const tpl = _templateStrings(strings);
381
380
  await this._active()(tpl);
382
381
  }
383
382
 
@@ -389,22 +388,20 @@ export class MigrationRunner {
389
388
  /** SELECT with exactly one bound parameter. */
390
389
  private async _selectParam<T>(sql: string, value: unknown): Promise<T[]> {
391
390
  const parts = sql.split("?");
392
- const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
391
+ const tpl = _templateStrings(parts);
393
392
  return this._active()<T>(tpl, value);
394
393
  }
395
394
 
396
395
  /** DML with N bound parameters. */
397
396
  private async _exec(sql: string, ...values: unknown[]): Promise<void> {
398
397
  const parts = sql.split("?");
399
- const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
398
+ const tpl = _templateStrings(parts);
400
399
  await this._active()(tpl, ...values);
401
400
  }
402
401
 
403
402
  private async _exec0<T>(sql: string): Promise<T[]> {
404
403
  const strings = [sql];
405
- const tpl = Object.assign(strings, {
406
- raw: strings,
407
- }) as TemplateStringsArray;
404
+ const tpl = _templateStrings(strings);
408
405
  return this._active()<T>(tpl);
409
406
  }
410
407
 
@@ -2,6 +2,7 @@ import { _getScopedDbConnection } from "../db/DB.ts";
2
2
  import { _getDialect } from "../model/BaseModel.ts";
3
3
  import { getDialect } from "../db/dialects/index.ts";
4
4
  import { Blueprint } from "./Blueprint.ts";
5
+ import { _templateStrings } from "../db/templateStrings.ts";
5
6
 
6
7
  // ── Helpers ───────────────────────────────────────────────────────────────────
7
8
 
@@ -16,7 +17,7 @@ async function ddl(sql: string): Promise<void> {
16
17
  // migration runner's `begin()` wrapper decorative.
17
18
  const conn = _getScopedDbConnection();
18
19
  const strings = [sql];
19
- const tpl = Object.assign(strings, { raw: strings }) as TemplateStringsArray;
20
+ const tpl = _templateStrings(strings);
20
21
  await conn(tpl);
21
22
  }
22
23
 
@@ -29,7 +30,7 @@ async function query<T = Record<string, unknown>>(sql: string, params: unknown[]
29
30
  // the tables that transaction has created, which a pooled connection cannot.
30
31
  const conn = _getScopedDbConnection();
31
32
  const parts = sql.split("?");
32
- const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
33
+ const tpl = _templateStrings(parts);
33
34
  return conn<T>(tpl, ...params);
34
35
  }
35
36
 
@@ -1,5 +1,6 @@
1
1
  import { _getDbConnection } from "../db/DB.ts";
2
2
  import { _getDialect } from "../model/BaseModel.ts";
3
+ import { _templateStrings } from "../db/templateStrings.ts";
3
4
 
4
5
  // ── DB column descriptor (normalised across dialects) ─────────────────────────
5
6
 
@@ -23,7 +24,7 @@ export interface LiveTable {
23
24
  async function queryRaw<T>(sql: string): Promise<T[]> {
24
25
  const conn = _getDbConnection();
25
26
  const strings = [sql];
26
- const tpl = Object.assign(strings, { raw: strings }) as TemplateStringsArray;
27
+ const tpl = _templateStrings(strings);
27
28
  return conn<T>(tpl);
28
29
  }
29
30
 
@@ -31,7 +32,7 @@ async function queryRaw<T>(sql: string): Promise<T[]> {
31
32
  async function queryParam<T>(sql: string, value: unknown): Promise<T[]> {
32
33
  const conn = _getDbConnection();
33
34
  const parts = sql.split("?");
34
- const tpl = Object.assign(parts, { raw: parts }) as TemplateStringsArray;
35
+ const tpl = _templateStrings(parts);
35
36
  return conn<T>(tpl, value);
36
37
  }
37
38