@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 +84 -0
- package/api-surface.md +7 -4
- package/package.json +3 -3
- package/src/model/BaseModel.ts +172 -37
- package/src/model/SoftDeletes.ts +8 -2
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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.
|
|
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.
|
|
35
|
-
"@zerotal/validator": "1.
|
|
34
|
+
"@zerotal/core": "1.15.1",
|
|
35
|
+
"@zerotal/validator": "1.15.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"typescript": "^5.8.0"
|
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;
|
|
@@ -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?:
|
|
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?:
|
|
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,
|
|
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
|
-
|
|
1796
|
-
|
|
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,
|
|
1799
|
-
newId = returning![ModelClass.primaryKey as string]
|
|
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
|
-
|
|
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])
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
2105
|
+
{ val: key },
|
|
1981
2106
|
]);
|
|
1982
|
-
if (!rows[0]) throw new ModelNotFoundError(ModelClass.name,
|
|
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:
|
|
2124
|
+
{ val: key },
|
|
1999
2125
|
]);
|
|
2000
|
-
if (!rows[0]) throw new ModelNotFoundError(ModelClass.name,
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|
package/src/model/SoftDeletes.ts
CHANGED
|
@@ -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
|
-
/**
|
|
67
|
-
|
|
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> {
|