@zerotal/orm 1.14.3 → 1.15.0

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,27 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.0] — 2026-09-04
12
+
13
+ ### Changed — **BREAKING**
14
+
15
+ - **BREAKING: `createdAt`, `updatedAt` and `deletedAt` are `Carbon`, not `Date`.** Every
16
+ `datetime` column hydrates as `Carbon`, these three included — so the declared type was
17
+ wrong on a freshly loaded row, and `order.createdAt.getTime()` type-checked and threw,
18
+ because `Carbon` has `toISOString()` and `valueOf()` but no `getTime()`.
19
+
20
+ Worse, the runtime class depended on how you got there: `Carbon` after a load, a raw
21
+ `Date` after a `save()`, a `touch()`, or a soft `delete()` wrote one straight back onto
22
+ the instance. The same property on the same model, matching its declared type in neither
23
+ case, and with no `@column` declaration to check the truth against.
24
+
25
+ All three are now `Carbon` everywhere, declared and at runtime. Code calling a `Date`
26
+ method on them was already failing at runtime and now fails to compile; `.toISOString()`
27
+ gives a string, `.valueOf()` epoch milliseconds, and `.toDate()` a native `Date`.
28
+
29
+ Compile and follow the errors — every one is a line that would have thrown against a
30
+ loaded row. See the [Upgrade Guide](/docs/upgrade#114-to-115).
31
+
11
32
  ## [1.12.0] — 2026-08-31
12
33
 
13
34
  ### Changed — **BREAKING**
package/api-surface.md CHANGED
@@ -3,6 +3,9 @@
3
3
  <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
4
  Run `bun run api:surface` to regenerate after an intentional API change. -->
5
5
 
6
+ > Not a guide — this is a flat, alphabetical snapshot used to detect unintended
7
+ > API changes. For how to use these, read the [documentation](https://zerotal.dev/docs).
8
+
6
9
  ## . `(./src/index.ts)`
7
10
 
8
11
  class ArrayCast = {
@@ -78,7 +81,7 @@ class BaseModel = {
78
81
  $dirty: () => Record<string, unknown>
79
82
  append: (...keys: string[]) => BaseModel
80
83
  associate: (relation: string, model: BaseModel) => BaseModel
81
- createdAt?: Date
84
+ createdAt?: Carbon
82
85
  decrement: (column: 'id' | 'createdAt' | 'updatedAt' | '__isZerotalModel' | 'fill' | 'forceFill' | 'save' | 'delete' | 'load' | 'loadMissing' | 'fresh' | 'refresh' | 'replicate' | 'touch' | 'is' | 'isNot' | 'increment' | 'decrement' | 'loadCount' | 'loadSum' | 'loadAvg' | 'loadMin' | 'loadMax' | 'makeHidden' | 'makeVisible' | 'append' | 'associate' | 'dissociate' | 'isDirty' | '$dirty' | 'markDirty' | 'toJSON', amount?: number) => Promise<BaseModel>
83
86
  delete: () => Promise<void>
84
87
  dissociate: (relation: string) => BaseModel
@@ -106,7 +109,7 @@ class BaseModel = {
106
109
  save: () => Promise<BaseModel>
107
110
  toJSON: () => Record<string, unknown>
108
111
  touch: () => Promise<BaseModel>
109
- updatedAt?: Date
112
+ updatedAt?: Carbon
110
113
  }
111
114
 
112
115
  class Blueprint = {
@@ -392,7 +395,7 @@ class Model = {
392
395
  $dirty: () => Record<string, unknown>
393
396
  append: (...keys: string[]) => BaseModel
394
397
  associate: (relation: string, model: BaseModel) => BaseModel
395
- createdAt?: Date
398
+ createdAt?: Carbon
396
399
  decrement: (column: 'id' | 'createdAt' | 'updatedAt' | '__isZerotalModel' | 'fill' | 'forceFill' | 'save' | 'delete' | 'load' | 'loadMissing' | 'fresh' | 'refresh' | 'replicate' | 'touch' | 'is' | 'isNot' | 'increment' | 'decrement' | 'loadCount' | 'loadSum' | 'loadAvg' | 'loadMin' | 'loadMax' | 'makeHidden' | 'makeVisible' | 'append' | 'associate' | 'dissociate' | 'isDirty' | '$dirty' | 'markDirty' | 'toJSON', amount?: number) => Promise<BaseModel>
397
400
  delete: () => Promise<void>
398
401
  dissociate: (relation: string) => BaseModel
@@ -420,7 +423,7 @@ class Model = {
420
423
  save: () => Promise<BaseModel>
421
424
  toJSON: () => Record<string, unknown>
422
425
  touch: () => Promise<BaseModel>
423
- updatedAt?: Date
426
+ updatedAt?: Carbon
424
427
  }
425
428
 
426
429
  class ModelChanged = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.14.3",
3
+ "version": "1.15.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -31,8 +31,8 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.14.3",
35
- "@zerotal/validator": "1.14.3"
34
+ "@zerotal/core": "1.15.0",
35
+ "@zerotal/validator": "1.15.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
@@ -930,16 +930,30 @@ export class BaseModel {
930
930
  /**
931
931
  * Creation timestamp, set on insert when {@link timestamps} is enabled.
932
932
  *
933
+ * A {@link Carbon}, like every other `datetime` column — not a `Date`. These
934
+ * two were declared as `Date` while hydration produced a `Carbon`, so
935
+ * `order.createdAt.getTime()` type-checked and threw, and `.slice(0, 10)` on
936
+ * a value assumed to be an ISO string threw differently. Neither has a column
937
+ * declaration in the model to read the truth off, which is exactly why the
938
+ * declaration here has to be right.
939
+ *
940
+ * Use `.toISOString()` for a string, `.valueOf()` for epoch milliseconds, and
941
+ * `.toDate()` when something really needs a native `Date`.
942
+ *
933
943
  * @category Timestamps
934
944
  */
935
- createdAt?: Date;
945
+ createdAt?: Carbon;
936
946
 
937
947
  /**
938
948
  * Last-update timestamp, bumped on every save when {@link timestamps} is enabled.
939
949
  *
950
+ * A {@link Carbon} — see {@link createdAt}. It used to depend on how you got
951
+ * here: a `Carbon` on a freshly loaded row, a raw `Date` on one you had just
952
+ * saved, from the same property on the same model.
953
+ *
940
954
  * @category Timestamps
941
955
  */
942
- updatedAt?: Date;
956
+ updatedAt?: Carbon;
943
957
 
944
958
  /** @internal Snapshot of attribute values at load/last-save, for dirty tracking. */
945
959
  private _original: Record<string, unknown> = {};
@@ -1849,7 +1863,10 @@ export class BaseModel {
1849
1863
  await HookRegistry.run(ModelClass, "beforeUpdate", this);
1850
1864
 
1851
1865
  const dirty = this.$dirty();
1852
- if (ModelClass.timestamps) dirty["updatedAt"] = new Date();
1866
+ // Carbon, not `new Date()`. The entries below are written back onto the
1867
+ // instance, so a plain Date here left `updatedAt` holding a different class
1868
+ // after a save than after a load — with the declared type matching neither.
1869
+ if (ModelClass.timestamps) dirty["updatedAt"] = Carbon.now();
1853
1870
 
1854
1871
  if (Object.keys(dirty).length > 0) {
1855
1872
  const entries = _writeDialect.run(dialect, () =>
@@ -1900,16 +1917,17 @@ export class BaseModel {
1900
1917
  await HookRegistry.run(ModelClass, "beforeDelete", this);
1901
1918
 
1902
1919
  if (ModelClass.softDeletes) {
1903
- const now = new Date();
1920
+ const now = Carbon.now();
1904
1921
  await runSegs(conn, [
1905
1922
  `UPDATE ${ModelClass.table} SET deleted_at = `,
1906
- { val: _writeDialect.run(dialect, () => _serializeDate(now)) },
1923
+ { val: _writeDialect.run(dialect, () => _serializeDate(now.toDate())) },
1907
1924
  ` WHERE ${ModelClass.primaryKey} = `,
1908
1925
  { val: this.id },
1909
1926
  ]);
1910
1927
  // `deletedAt` lives on the SoftDeletes mixin; this branch only runs for models
1911
- // that compose it (softDeletes === true).
1912
- (this as { deletedAt?: Date | null }).deletedAt = now;
1928
+ // that compose it (softDeletes === true). Carbon, to match what a reload of
1929
+ // this same row would put there.
1930
+ (this as { deletedAt?: Carbon | null }).deletedAt = now;
1913
1931
  } else {
1914
1932
  await runSegs(conn, [
1915
1933
  `DELETE FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
@@ -2028,7 +2046,7 @@ export class BaseModel {
2028
2046
  async touch(): Promise<this> {
2029
2047
  const ModelClass = this.constructor as typeof BaseModel;
2030
2048
  if (!ModelClass.timestamps) return this;
2031
- this.updatedAt = new Date();
2049
+ this.updatedAt = Carbon.now();
2032
2050
  this.markDirty("updatedAt" as keyof this);
2033
2051
  return this.save();
2034
2052
  }
@@ -24,6 +24,7 @@ import { _resolveConn, type BaseModel } from "./BaseModel.ts";
24
24
  import { ModelQueryBuilder } from "./ModelQueryBuilder.ts";
25
25
  import { QueryBuilder } from "../db/QueryBuilder.ts";
26
26
  import type { Constructor } from "./mixins.ts";
27
+ import type { Carbon } from "@zerotal/core/carbon";
27
28
 
28
29
  // Structural view of the concrete model class used by the static scopes.
29
30
  interface SoftDeleteModelClass<T extends BaseModel> {
@@ -63,8 +64,13 @@ export function SoftDeletes<TBase extends Constructor>(Base: TBase) {
63
64
  /** Engine switch — query scoping + schema `deleted_at` provisioning read this. */
64
65
  static softDeletes = true;
65
66
 
66
- /** When the row was soft-deleted, or null/undefined when live. */
67
- declare deletedAt?: Date | null;
67
+ /**
68
+ * When the row was soft-deleted, or null/undefined when live.
69
+ *
70
+ * A {@link Carbon}, like `createdAt`/`updatedAt` and every other `datetime`
71
+ * column — `.toISOString()` for a string, `.toDate()` for a native `Date`.
72
+ */
73
+ declare deletedAt?: Carbon | null;
68
74
 
69
75
  /** A query that INCLUDES soft-deleted rows (bypasses the default scope). */
70
76
  static withTrashed<T extends BaseModel>(this: SoftDeleteModelClass<T>): ModelQueryBuilder<T> {