@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 +82 -0
- package/package.json +3 -3
- package/src/db/DB.ts +4 -3
- package/src/db/QueryBuilder.ts +2 -2
- package/src/db/templateStrings.ts +19 -0
- package/src/index.ts +1 -0
- package/src/model/BaseModel.ts +146 -29
- package/src/provider/DatabaseProvider.ts +4 -13
- package/src/schema/MigrationRunner.ts +5 -8
- package/src/schema/Schema.ts +3 -2
- package/src/schema/SchemaInspector.ts +3 -2
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.
|
|
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.
|
|
35
|
-
"@zerotal/validator": "1.15.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
250
|
+
const tpl = _templateStrings(parts);
|
|
250
251
|
const rows = await conn<T>(tpl, ...bindings);
|
|
251
252
|
FrameworkEvents.emit(
|
|
252
253
|
new QueryExecuted(
|
package/src/db/QueryBuilder.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/model/BaseModel.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
1810
|
-
|
|
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,
|
|
1813
|
-
newId = returning![ModelClass.primaryKey as string]
|
|
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
|
-
|
|
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])
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
2105
|
+
{ val: key },
|
|
1999
2106
|
]);
|
|
2000
|
-
if (!rows[0]) throw new ModelNotFoundError(ModelClass.name,
|
|
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:
|
|
2124
|
+
{ val: key },
|
|
2017
2125
|
]);
|
|
2018
|
-
if (!rows[0]) throw new ModelNotFoundError(ModelClass.name,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
406
|
-
raw: strings,
|
|
407
|
-
}) as TemplateStringsArray;
|
|
404
|
+
const tpl = _templateStrings(strings);
|
|
408
405
|
return this._active()<T>(tpl);
|
|
409
406
|
}
|
|
410
407
|
|
package/src/schema/Schema.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
35
|
+
const tpl = _templateStrings(parts);
|
|
35
36
|
return conn<T>(tpl, value);
|
|
36
37
|
}
|
|
37
38
|
|