@zerotal/orm 1.0.4 → 1.3.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,77 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.3.0] — 2026-08-09
12
+
13
+ ### Changed — BREAKING
14
+
15
+ - **`BaseModelWith(...)` is replaced by the `Model.using(...)` static.** Mixin composition is now
16
+ a property of the base class rather than a helper shipped alongside it, so there is one idiom to
17
+ learn and nothing extra to import.
18
+
19
+ ```ts
20
+ // before
21
+ import { BaseModelWith } from "@zerotal/orm";
22
+ class User extends BaseModelWith(Authenticatable, Permissions, Roles) {}
23
+
24
+ // after
25
+ import { Model } from "@zerotal/orm";
26
+ class User extends Model.using(Authenticatable, Permissions, Roles) {}
27
+ ```
28
+
29
+ Run `bun run scripts/codemod-mixin-composition.ts` to rewrite call sites and imports.
30
+
31
+ How mixins are **authored** is unchanged — `<T extends Constructor>(Base: T) => class extends Base`
32
+ still works exactly as before, and every shipped mixin (`SoftDeletes`, `State`, `Authenticatable`,
33
+ `Roles`, `Permissions`, `Notifiable`, `Tenantable`, `Auditable`, …) keeps its signature. The
34
+ `Constructor` and `Mixin` types are still exported; `Compose` (the type of `Model.using`) joins
35
+ them. Mixin authors declaring columns still call `registerColumn` imperatively.
36
+
37
+ `with` was deliberately **not** used for this. It is reserved for the eager-load static
38
+ (`User.with("posts")`), the one conspicuous gap in the model's existing query-forwarder family
39
+ (`where`, `whereIn`, `orderBy`, `latest`, `first`, `paginate`, `find`, `all`, `count`, …).
40
+
41
+ ### Changed
42
+
43
+ - **`Model` is now the canonical name for the base class; `BaseModel` is the alias.** They are the
44
+ same class object and both remain exported, so no code breaks — but `class User extends Model {}`
45
+ is the documented form from here, mirroring Flow's `class PostsPage extends Component {}`.
46
+ `BaseModel` was previously the canonical name and `Model` an unused compat alias added in 1.0.2.
47
+
48
+ ### Added
49
+
50
+ - **`using` composes onto any class in the chain, not just the root.** An app-level base model can
51
+ now carry mixins without being flattened out of the prototype chain — `AppModel.using(SoftDeletes)`
52
+ keeps `AppModel` and its statics in the lineage. `BaseModelWith` hardcoded `BaseModel`, so this
53
+ previously required hand-nesting.
54
+ - **Composition chains.** The composed class carries `using` itself, so
55
+ `Model.using(a, b).using(c, d)` works past the 8-mixin overload set — which is why the overload
56
+ set shrank from 20 hand-written arities to 8 without losing any capability.
57
+
58
+ ## [1.1.0] — 2026-08-08
59
+
60
+ ### Fixed
61
+
62
+ - **A `json` column returns the type it was given.** Writing skipped `JSON.stringify` for values that were already strings, so a string went into the column as bare characters — `62812345678`, not `"62812345678"` — and the read side's `JSON.parse` turned it back into a number. A `json`-cast setting holding an account number came back as a number, and only for _some_ values, since a string that fails to parse fell through unchanged. Encoding is now symmetric in both directions, and `where()` against a `json` column encodes the same way, so a query finds what a write stores. **Upgrade note:** rows written by an older version hold bare scalars, so a string column may still read back as a number, and a `where()` on a string will not match those older rows — they are stored unquoted. Only affects bare scalars in `json`/`array` columns; objects and arrays were always encoded and are untouched.
63
+ - **`bun zt make:model` generates a file that parses.** The stub emitted `@table('posts').withTimestamps()`, which is not valid decorator syntax — the grammar allows a call at the end of the chain, not in the middle — so every generated model failed with `Expected "class" but found "."`. The stub now emits plain `@table('posts')`; timestamps are on by default and the chained form needs outer parentheses, `@(table("x").withoutTimestamps())`. The same broken form is corrected in the `BaseModel` docblocks, and every generated stub is now parsed by a test rather than checked for substrings.
64
+ - **A column `default` is applied on insert.** A declared field that was never assigned was written as an explicit `NULL`, so the INSERT named the column, the database never applied its own default, and a `NOT NULL` column failed outright — on a model and migration that both declared `default: 0`. `undefined` now means "I didn't say": the declared default is used, or the column is omitted so the database decides. An explicit `null` still stores `NULL`.
65
+ - **A `Date` compared against a timestamp column matches again.** Bound values are serialised through the column's cast metadata, but the framework-managed `created_at` / `updated_at` / `deleted_at` carry no `@column` registration — so a `Date` was bound raw and matched nothing. `where("created_at", ">=", monthStart)` is the commonest reporting query there is, and it silently returned zero rows: a dashboard reading "0 this month" looks like a quiet month, not a broken query.
66
+
67
+ ### Added
68
+
69
+ - `Schema.alter(...)` as an alias of `Schema.table(...)`, and `table.datetime(...)` as an alias of `table.dateTime(...)`. Both are the names other frameworks use, neither was a type error because the blueprint callback is loosely typed, and both therefore failed as a `TypeError` mid-migration — after earlier statements had already run, leaving the schema half-changed.
70
+ - `@column("string", { nullable: true })` — a two-argument form. The shorthand keeps its type and cast; the options cannot contradict them.
71
+ - `@column({ unique: true })` and `@column({ index: true })`, carried through to both `migrate:generate` and `synchronize`. Uniqueness is usually a correctness property, and it was not expressible at all.
72
+ - Generated migrations index any `*_id` column. The reference cannot always be inferred, but the index can, and an unindexed foreign key is a table scan on every join.
73
+ - `"text"` is its own storage type rather than an alias for `"string"`, so a real `TEXT` column is expressible — the distinction matters on Postgres and MySQL.
74
+
75
+ ### Changed
76
+
77
+ - **`create()` narrows its payload to the mass-assignable columns** when a model declares `fillable` as a literal tuple (`as const`). A required column deliberately kept out of `fillable` was demanded by `InsertPayload` and refused by `fill()` at runtime: the type required exactly what the runtime forbade, and there was no spelling of `create()` that satisfied both. Models without a literal list are unaffected.
78
+ - `static fillable` / `static guarded` accept `readonly string[]`.
79
+ - A migration that fails with "already exists" now says that `database.synchronize` is the usual cause, since the raw driver error names nothing actionable.
80
+ - `make:model` generates `fillable` as a literal tuple and documents that a nullable column is declared `?: T | undefined` — under the scaffold's `exactOptionalPropertyTypes`, `?: T` cannot be assigned `undefined`, so the field could never be cleared.
81
+
11
82
  ## [1.0.3] — 2026-08-07
12
83
 
13
84
  ### Changed
package/README.md CHANGED
@@ -43,11 +43,11 @@ export default DatabaseConfig({
43
43
  ### Define a model
44
44
 
45
45
  ```ts
46
- import { BaseModel, column, table, belongsTo, hasMany } from "@zerotal/orm";
46
+ import { Model, column, table, belongsTo, hasMany } from "@zerotal/orm";
47
47
  import type { Columns } from "@zerotal/orm";
48
48
 
49
49
  @(table("posts").withTimestamps().withSoftDeletes())
50
- export class Post extends BaseModel {
50
+ export class Post extends Model {
51
51
  static fillable: Columns<Post>[] = ["title", "body", "status", "userId"];
52
52
 
53
53
  @column("string") title!: string;
@@ -144,7 +144,7 @@ This package exposes two subpath entry points:
144
144
 
145
145
  Main exports from the default entry point:
146
146
 
147
- - **Models** — `BaseModel` / `Model`, `ModelQueryBuilder`, `DB`, `QueryBuilder`
147
+ - **Models** — `Model` (aka `BaseModel`), `ModelQueryBuilder`, `DB`, `QueryBuilder`
148
148
  - **Decorators** — `column`, `table`, `belongsTo`, `hasMany`, `hasOne`, `manyToMany`, `morphTo`, `morphMany`, `morphOne`, `hasManyThrough`, `hasOneThrough`, `morphToMany`, `morphedByMany`
149
149
  - **Schema / migrations** — `Schema`, `Blueprint`, `Migration`, `MigrationRunner`, `SchemaInspector`, `ModelInspector`, `SchemaDiffer`, `synchronizeSchema`
150
150
  - **Seeding** — `Seeder`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.0.4",
3
+ "version": "1.3.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,8 +30,8 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.0.4",
34
- "@zerotal/validator": "1.0.4"
33
+ "@zerotal/core": "1.3.0",
34
+ "@zerotal/validator": "1.3.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0"
@@ -7,13 +7,23 @@ import { createMigrationFile } from "./MakeMigrationCommand.ts";
7
7
  export function modelStub(name: string, tableName: string): string {
8
8
  return `import { BaseModel, column, table } from '@zerotal/orm';
9
9
 
10
- @table('${tableName}').withTimestamps()
10
+ @table('${tableName}')
11
11
  export class ${name} extends BaseModel {
12
12
  // Models guard every attribute by default. List the columns that may be
13
13
  // mass-assigned from user input via create() / fill().
14
- static override fillable: string[] = ['name'];
14
+ //
15
+ // \`as const\` is what lets create() narrow its payload to exactly these columns,
16
+ // so a column kept out of this list is neither required by the type nor accepted
17
+ // at runtime — instead of being demanded by one and refused by the other.
18
+ static override fillable = ['name'] as const;
15
19
 
16
20
  @column() name!: string;
21
+
22
+ // A nullable column is declared \`?: T | undefined\`, not \`?: T\`. The scaffold
23
+ // enables exactOptionalPropertyTypes, under which \`?: T\` means "may be absent,
24
+ // but never undefined" — so clearing the field, which is the whole point of a
25
+ // nullable column, would not typecheck.
26
+ // @column({ nullable: true }) note?: string | undefined;
17
27
  }
18
28
  `;
19
29
  }
@@ -9,10 +9,31 @@ import { ZerotalError } from "@zerotal/core";
9
9
  */
10
10
  export class MigrationError extends ZerotalError {
11
11
  constructor(migrationName: string, cause: Error) {
12
- super(`Migration '${migrationName}' failed: ${cause.message}`, "E_MIGRATION_FAILED", 500, {
13
- migration: migrationName,
14
- });
12
+ super(
13
+ `Migration '${migrationName}' failed: ${cause.message}` + _syncHint(cause),
14
+ "E_MIGRATION_FAILED",
15
+ 500,
16
+ { migration: migrationName },
17
+ );
15
18
  // Preserve the original error so its stack and message survive re-throwing.
16
19
  this.cause = cause;
17
20
  }
18
21
  }
22
+
23
+ /**
24
+ * "table X already exists" on a migration that is supposed to create it almost always
25
+ * means boot-time schema sync got there first: `database.synchronize` built the table
26
+ * from the model before the migration ran. The two cannot both own a table, and the
27
+ * raw driver error gives no hint that a config flag is responsible — so name it here,
28
+ * at the only moment anyone is looking.
29
+ */
30
+ function _syncHint(cause: Error): string {
31
+ if (!/already exists/i.test(cause.message)) return "";
32
+ return (
33
+ `\n\n This usually means database.synchronize created the table from your model ` +
34
+ `before the migration ran.\n` +
35
+ ` A table can have one owner: set synchronize: false in config/database.ts and let ` +
36
+ `migrations build the schema,\n` +
37
+ ` or delete the migration and let synchronize own it.`
38
+ );
39
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A Bun-native Active Record ORM built on `Bun.sql`.
3
3
  *
4
- * Models extend {@link BaseModel}: you declare columns with {@link column | `@column`},
4
+ * Models extend {@link Model}: you declare columns with {@link column | `@column`},
5
5
  * relationships with decorators like {@link hasMany | `@hasMany`} and
6
6
  * {@link belongsTo | `@belongsTo`}, and then query and persist through the model's
7
7
  * static and instance methods. Under the hood a {@link QueryBuilder} routes every
@@ -11,17 +11,17 @@
11
11
  * using the {@link Schema} facade and the {@link Blueprint} table builder.
12
12
  *
13
13
  * Mass assignment is **guarded by default** — a model with neither `fillable` nor
14
- * `guarded` declared rejects all attributes in {@link BaseModel.fill | `fill()`}.
14
+ * `guarded` declared rejects all attributes in {@link Model.fill | `fill()`}.
15
15
  * Soft deletes and state machines are opt-in mixins composed via
16
- * {@link BaseModelWith}. The ORM's CLI commands (`migrate`, `make:model`, …) live
16
+ * `Model.using(...)`. The ORM's CLI commands (`migrate`, `make:model`, …) live
17
17
  * under the `@zerotal/orm/commands` subpath.
18
18
  *
19
19
  * @example Define a model
20
20
  * ```ts
21
- * import { BaseModel, column, hasMany, type HasMany } from "@zerotal/orm";
21
+ * import { Model, column, hasMany, type HasMany } from "@zerotal/orm";
22
22
  * import { Post } from "./Post.ts";
23
23
  *
24
- * export class User extends BaseModel {
24
+ * export class User extends Model {
25
25
  * @column({ primary: true }) id!: number;
26
26
  * @column() email!: string;
27
27
  * @column() name!: string;
@@ -70,12 +70,14 @@
70
70
 
71
71
  // @zerotal/orm — public API barrel
72
72
 
73
- export { BaseModel, Model } from "./model/BaseModel.ts";
74
- export { BaseModelWith } from "./model/mixins.ts";
75
- export type { Constructor, Mixin } from "./model/mixins.ts";
76
- // State-machine behaviour is an opt-in mixin — compose with `BaseModelWith(State)`.
73
+ // `Model` is the canonical base class; `BaseModel` is the same class under its original name.
74
+ export { Model, BaseModel } from "./model/BaseModel.ts";
75
+ // Mixin authoring types. Compose them onto a model with the `Model.using(...)` static —
76
+ // `class User extends Model.using(Authenticatable, Roles)`.
77
+ export type { Constructor, Mixin, Compose } from "./model/mixins.ts";
78
+ // State-machine behaviour is an opt-in mixin — compose with `Model.using(State)`.
77
79
  export { State } from "./model/State.ts";
78
- // Soft deletes are opt-in — compose with `BaseModelWith(SoftDeletes)`.
80
+ // Soft deletes are opt-in — compose with `Model.using(SoftDeletes)`.
79
81
  export { SoftDeletes } from "./model/SoftDeletes.ts";
80
82
  export type {
81
83
  StateDefinition,
@@ -143,7 +145,7 @@ export { column, columnRegistry } from "./model/decorators/column.ts";
143
145
  export type { ColumnOptions, ColumnShorthand } from "./model/decorators/column.ts";
144
146
  export { registerModel, modelByName, modelsByName } from "./model/decorators/_metadata.ts";
145
147
  // Imperative column registration — for mixin authors composing model behaviour with
146
- // BaseModelWith (the @column decorator can't run inside a returned class expression).
148
+ // Model.using (the @column decorator can't run inside a returned class expression).
147
149
  export { registerColumn, columnsFor } from "./model/decorators/_metadata.ts";
148
150
  export { table } from "./model/decorators/table.ts";
149
151
  export type { TableDecoratorBuilder, TableOptions } from "./model/decorators/table.ts";
@@ -33,9 +33,11 @@ import {
33
33
  } from "../errors/index.ts";
34
34
  import { type ManyToMany } from "./relations/RelationRegistry.ts";
35
35
  import { installReactiveAccessors, type ColumnOptions } from "./decorators/column.ts";
36
+ import { _compose } from "./mixins.ts";
37
+ import type { Compose } from "./mixins.ts";
36
38
  import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
37
39
  import { TransactionContext } from "../db/TransactionContext.ts";
38
- import type { InsertPayload, UpdatePayload } from "./payload.ts";
40
+ import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
39
41
  import type { WhereOperator, OrderDirection } from "../db/types.ts";
40
42
 
41
43
  let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
@@ -311,8 +313,13 @@ function applyCastSet(value: unknown, cast: StringCast): unknown {
311
313
  return value;
312
314
  case "array":
313
315
  case "json":
314
- if (typeof value !== "string") return JSON.stringify(value);
315
- return value;
316
+ // Encode unconditionally. Skipping strings was meant to avoid double-encoding a
317
+ // value that was already JSON text, but that is indistinguishable from a string
318
+ // someone means to store — and guessing wrong changed the value's *type* between
319
+ // write and read: `"62812345678"` went in as bare characters and came back out of
320
+ // `JSON.parse` as a number. Encoding both ways symmetrically is the only version
321
+ // of this that round-trips.
322
+ return JSON.stringify(value);
316
323
  case "date":
317
324
  if (value instanceof Carbon) return _serializeDate(value.toDate());
318
325
  if (value instanceof Date) return _serializeDate(value);
@@ -366,7 +373,9 @@ function _serializeForWrite(
366
373
  serializedVal = applyCastSet(val, castOpt);
367
374
  } else if (colType === "boolean" && val !== null && val !== undefined) {
368
375
  serializedVal = val ? 1 : 0;
369
- } else if (colType === "json" && val !== null && typeof val !== "string") {
376
+ } else if (colType === "json" && val !== null) {
377
+ // See applyCastSet: strings are encoded too, so the column always holds valid JSON
378
+ // and a read returns the type that was written.
370
379
  serializedVal = JSON.stringify(val);
371
380
  } else {
372
381
  serializedVal = val;
@@ -460,7 +469,7 @@ export interface ScopeApplicator {
460
469
  * — TypeScript will catch typos and non-existent column references.
461
470
  *
462
471
  * @example
463
- * \@table("users").withTimestamps()
472
+ * \@table("users")
464
473
  * export class User extends BaseModel {
465
474
  * static fillable: Columns<User>[] = ["name", "email", "role"];
466
475
  * static hidden: Columns<User>[] = ["password"];
@@ -516,7 +525,7 @@ export type Columns<T> = {
516
525
  * @example
517
526
  * Defining a model with `@column`:
518
527
  * ```ts
519
- * @table("users").withTimestamps()
528
+ * @table("users")
520
529
  * export class User extends BaseModel {
521
530
  * static fillable: Columns<User>[] = ["name", "email", "password"];
522
531
  * static hidden: Columns<User>[] = ["password"];
@@ -560,6 +569,37 @@ export class BaseModel {
560
569
  */
561
570
  declare readonly __isZerotalModel: true;
562
571
 
572
+ /**
573
+ * Compose one or more model mixins onto this class, folding them left-to-right, so reusable
574
+ * model behaviour (auth contract, roles, permissions, soft deletes, tenancy, …) stacks flat
575
+ * instead of nesting.
576
+ *
577
+ * @remarks
578
+ * Each mixin receives the accumulated base and returns an extended class, so this class's full
579
+ * static surface (`query()`, `find()`, `create()`, scopes, …) and every mixin's instance and
580
+ * static members flow through to the composed class — fully type-checked. Prefer this over
581
+ * hand-nesting mixins (`Roles(Permissions(AuthUser))`), which reads inside-out and repeats the
582
+ * base.
583
+ *
584
+ * `using` composes onto whatever class it is called on, so it also works on an intermediate
585
+ * model base, and the composed class carries `using` itself, so `Model.using(a, b).using(c)`
586
+ * chains past the 8-mixin overload set.
587
+ *
588
+ * Mixin authors declaring columns must call {@link registerColumn} imperatively — the `@column`
589
+ * decorator cannot run inside a returned class expression.
590
+ *
591
+ * @param mixins - Mixin factories applied in order; each receives the class the previous one produced.
592
+ * @returns A model class extending this one with every mixin applied.
593
+ *
594
+ * @example
595
+ * ```ts
596
+ * class User extends Model.using(Authenticatable, Permissions, Roles) {}
597
+ * ```
598
+ *
599
+ * @category Composition
600
+ */
601
+ static using: Compose = _compose;
602
+
563
603
  /**
564
604
  * Database table this model maps to. Usually set for you by the `@table("…")`
565
605
  * decorator; assign directly to override.
@@ -660,7 +700,7 @@ export class BaseModel {
660
700
  *
661
701
  * @category Attributes & mass assignment
662
702
  */
663
- static fillable?: string[];
703
+ static fillable?: readonly string[];
664
704
 
665
705
  /**
666
706
  * Denylist of camelCase field names blocked from create() / fill().
@@ -669,7 +709,7 @@ export class BaseModel {
669
709
  *
670
710
  * @category Attributes & mass assignment
671
711
  */
672
- static guarded?: string[];
712
+ static guarded?: readonly string[];
673
713
 
674
714
  /**
675
715
  * Disable mass-assignment protection for this model — every attribute passed
@@ -1198,6 +1238,11 @@ export class BaseModel {
1198
1238
  * Mass-assign `data` (respecting {@link fillable} / {@link guarded}) onto a new
1199
1239
  * instance and {@link save} it, returning the persisted model.
1200
1240
  *
1241
+ * When the model declares `static fillable` as a literal tuple (`as const`), the
1242
+ * payload type is narrowed to exactly those columns — so a column deliberately kept
1243
+ * out of `fillable` is neither required nor accepted here, instead of being demanded
1244
+ * by the type and rejected by {@link fill} at runtime.
1245
+ *
1201
1246
  * @throws {MassAssignmentError} when `data` contains a non-fillable key.
1202
1247
  *
1203
1248
  * @example
@@ -1205,7 +1250,10 @@ export class BaseModel {
1205
1250
  *
1206
1251
  * @category Persistence
1207
1252
  */
1208
- static async create<T extends BaseModel>(this: ModelCtor<T>, data: InsertPayload<T>): Promise<T> {
1253
+ static async create<T extends BaseModel, F extends string = string>(
1254
+ this: ModelCtor<T> & { fillable?: readonly F[] | undefined },
1255
+ data: FillablePayload<T, F>,
1256
+ ): Promise<T> {
1209
1257
  const inst = new this();
1210
1258
  inst.fill(data as UpdatePayload<T>);
1211
1259
  return inst.save() as Promise<T>;
@@ -1604,9 +1652,12 @@ export class BaseModel {
1604
1652
  // On INSERT: hash every hashable field that holds a non-empty string.
1605
1653
  // On UPDATE: only hash hashable fields whose value changed since the last
1606
1654
  // save (avoids re-hashing an already-stored bcrypt hash).
1655
+ // One keyed view of the instance for the whole method: the hashable pass and the
1656
+ // insert's default-filling both need to read and write columns by name.
1657
+ const self = this as unknown as Record<string, unknown>;
1658
+
1607
1659
  const hashable = ModelClass.hashable;
1608
1660
  if (hashable && hashable.length > 0) {
1609
- const self = this as unknown as Record<string, unknown>;
1610
1661
  if (!this._exists) {
1611
1662
  for (const key of hashable) {
1612
1663
  const val = self[key];
@@ -1635,7 +1686,24 @@ export class BaseModel {
1635
1686
  const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1636
1687
  const r: Record<string, unknown> = {};
1637
1688
  for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
1638
- r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1689
+ // A declared field that was never assigned is `undefined`, and writing that
1690
+ // as an explicit NULL made `@column({ default: … })` inert: the INSERT named
1691
+ // the column, so the database never applied its own default and a NOT NULL
1692
+ // column failed outright. Fall back to the declared default, and if there
1693
+ // isn't one, omit the column entirely so the database decides.
1694
+ //
1695
+ // Only `undefined` is treated this way. An explicit `null` is a deliberate
1696
+ // "store NULL" and still writes one.
1697
+ let effective = val;
1698
+ if (effective === undefined) {
1699
+ const declared = colReg?.get(key)?.default;
1700
+ if (declared === undefined) continue; // omit → database default / NULL
1701
+ effective = typeof declared === "function" ? (declared as () => unknown)() : declared;
1702
+ // Keep the instance consistent with the row we are about to write, so the
1703
+ // value is readable straight after save() without a reload.
1704
+ self[key] = effective;
1705
+ }
1706
+ r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg);
1639
1707
  }
1640
1708
  if (ModelClass.timestamps) {
1641
1709
  const now = _serializeDate(new Date());
@@ -2495,5 +2563,7 @@ function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
2495
2563
  (inst as unknown as { _original: Record<string, unknown> })._original = orig;
2496
2564
  }
2497
2565
 
2498
- // Backward-compat alias (index.ts exports `Model`)
2566
+ // `Model` is the canonical name at the declaration site — `class User extends Model.using(…)`
2567
+ // mirrors Flow's `class PostsPage extends Component.using(…)`. `BaseModel` remains exported as
2568
+ // an alias (same class object) for code that references the base class by that name.
2499
2569
  export { BaseModel as Model };
@@ -1,6 +1,6 @@
1
1
  import type { SQLInstance } from "../db/sql-types.ts";
2
2
  import { Carbon } from "@zerotal/core/carbon";
3
- import { QueryBuilder, OPERATORS, _inlineValue } from "../db/QueryBuilder.ts";
3
+ import { QueryBuilder, OPERATORS, _inlineValue, dialectFor } from "../db/QueryBuilder.ts";
4
4
  import {
5
5
  toCamelKey as _toCamel,
6
6
  toSnakeColumn as _toSnakeColumn,
@@ -1261,11 +1261,26 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
1261
1261
  if (colType === "boolean") {
1262
1262
  return value ? 1 : 0;
1263
1263
  }
1264
- if (colType === "json" && typeof value !== "string") {
1264
+ if (colType === "json") {
1265
+ // Match how writes encode: strings are stringified too, so a column holding
1266
+ // `"051001"` is found by `where("value", "051001")` rather than only by a caller
1267
+ // who knows to pre-encode the quotes themselves.
1265
1268
  return JSON.stringify(value);
1266
1269
  }
1267
1270
  // Carbon instances always serialize to ISO string regardless of column metadata.
1268
1271
  if (value instanceof Carbon) return value.toDatabase();
1272
+ // …and so does a plain Date. Without this, a comparison against a column the cast
1273
+ // lookup above can't see — most importantly the framework-managed `created_at` /
1274
+ // `updated_at` / `deleted_at`, which carry no `@column` metadata — bound the Date
1275
+ // object itself and matched nothing. `where("created_at", ">=", monthStart)` is the
1276
+ // commonest reporting query there is, and it silently returned zero rows: a
1277
+ // dashboard reading "0 this month" looks like a quiet month, not a broken query.
1278
+ // A Date in a comparison is unambiguous intent, so serialize it the way writes do.
1279
+ if (value instanceof Date) {
1280
+ return dialectFor(this._sql) === "mysql"
1281
+ ? value.toISOString().replace("T", " ").slice(0, 19)
1282
+ : value.toISOString();
1283
+ }
1269
1284
  return value;
1270
1285
  }
1271
1286
 
@@ -4,10 +4,10 @@
4
4
  // `deletedAt`, `forceDelete()`, `restore()`, `trashed()`, and the `withTrashed()` /
5
5
  // `onlyTrashed()` query scopes. A hard-delete model has none of these.
6
6
  //
7
- // import { BaseModelWith, SoftDeletes } from "@zerotal/orm";
7
+ // import { Model, SoftDeletes } from "@zerotal/orm";
8
8
  //
9
9
  // @table("posts")
10
- // class Post extends BaseModelWith(SoftDeletes) {
10
+ // class Post extends Model.using(SoftDeletes) {
11
11
  // @column() title!: string;
12
12
  // }
13
13
  //
@@ -47,7 +47,7 @@ interface SoftDeleteModelClass<T extends BaseModel> {
47
47
  * @example
48
48
  * ```ts
49
49
  * @table("posts")
50
- * class Post extends BaseModelWith(SoftDeletes) {
50
+ * class Post extends Model.using(SoftDeletes) {
51
51
  * @column() title!: string;
52
52
  * }
53
53
  *
@@ -4,7 +4,7 @@
4
4
  // workflow carry the API — `transitionTo` / `forceState` / `onTransition` / the
5
5
  // `states` + `stateField` statics never appear on models that don't use them.
6
6
  //
7
- // import { BaseModelWith } from "@zerotal/orm";
7
+ // import { Model, State } from "@zerotal/orm";
8
8
  //
9
9
  // const States = {
10
10
  // pending: { canTransitionTo: ["active", "cancelled"] as const },
@@ -14,7 +14,7 @@
14
14
  // cancelled: { canTransitionTo: [] as const },
15
15
  // } as const;
16
16
  //
17
- // class Subscription extends BaseModelWith(State) {
17
+ // class Subscription extends Model.using(State) {
18
18
  // static states = States;
19
19
  // @column() status!: keyof typeof States;
20
20
  // }
@@ -132,7 +132,7 @@ interface StateModelClass {
132
132
  * cancelled: { canTransitionTo: [] as const },
133
133
  * } as const;
134
134
  *
135
- * class Subscription extends BaseModelWith(State) {
135
+ * class Subscription extends Model.using(State) {
136
136
  * static states = States;
137
137
  * @column() status!: keyof typeof States;
138
138
  * }
@@ -19,8 +19,8 @@ export { columnRegistry };
19
19
  *
20
20
  * | Shorthand | Equivalent options |
21
21
  * |-------------|--------------------------------------------------|
22
- * | `"string"` | `{ type: "string" }` |
23
- * | `"text"` | `{ type: "string" }` (large text, alias) |
22
+ * | `"string"` | `{ type: "string" }` (VARCHAR) |
23
+ * | `"text"` | `{ type: "text" }` (unbounded TEXT) |
24
24
  * | `"integer"` | `{ type: "number", cast: "integer" }` |
25
25
  * | `"number"` | `{ type: "number" }` |
26
26
  * | `"float"` | `{ type: "number", cast: "float" }` |
@@ -68,14 +68,32 @@ export type ColumnShorthand =
68
68
  * ```
69
69
  */
70
70
  export interface ColumnOptions {
71
- /** Logical storage type; drives schema generation and auto-migration. @default "string" */
72
- type?: "string" | "number" | "boolean" | "datetime" | "json";
71
+ /**
72
+ * Logical storage type; drives schema generation and auto-migration.
73
+ *
74
+ * `"string"` is a bounded VARCHAR; `"text"` is the unbounded TEXT type — a real
75
+ * distinction on Postgres and MySQL, where a long body in a VARCHAR(255) is an
76
+ * error rather than a slow column.
77
+ *
78
+ * @default "string"
79
+ */
80
+ type?: "string" | "text" | "number" | "boolean" | "datetime" | "json";
73
81
  /** Mark this column as the table's primary key. */
74
82
  primary?: boolean;
75
83
  /** Allow SQL `NULL` for this column. */
76
84
  nullable?: boolean;
77
85
  /** Default value applied when none is provided. */
78
86
  default?: unknown;
87
+ /**
88
+ * Add a unique index on this column during schema generation / auto-migration.
89
+ *
90
+ * Declared here rather than left to a hand-written migration because uniqueness is
91
+ * usually a correctness property (a webhook idempotency key, an invoice number),
92
+ * and `migrate:generate` can only emit constraints it can see declared.
93
+ */
94
+ unique?: boolean;
95
+ /** Add a plain (non-unique) index on this column. */
96
+ index?: boolean;
79
97
  /**
80
98
  * Shorthand cast types automatically serialize/deserialize the column value.
81
99
  * Can also be a custom object with `get`/`set` functions for full control.
@@ -113,7 +131,7 @@ export interface ColumnOptions {
113
131
  */
114
132
  const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
115
133
  string: { type: "string" },
116
- text: { type: "string" },
134
+ text: { type: "text" },
117
135
  integer: { type: "number", cast: "integer" },
118
136
  number: { type: "number" },
119
137
  float: { type: "number", cast: "float" },
@@ -124,7 +142,31 @@ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
124
142
  array: { type: "json", cast: "array" },
125
143
  };
126
144
 
127
- function resolveOptions(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
145
+ /**
146
+ * Resolve the decorator's arguments to one options object.
147
+ *
148
+ * `extra` is the second argument of the `@column("string", { nullable: true })` form —
149
+ * the shorthand covers the common case and nullability is the most common modifier, so
150
+ * writing `@column({ type: "number", cast: "integer", nullable: true })` just to say
151
+ * "nullable int" is noise. The shorthand's own `type`/`cast` win: `extra` is typed as
152
+ * `Omit<ColumnOptions, "type">`, so it cannot contradict the type it is modifying.
153
+ */
154
+ function resolveOptions(
155
+ arg?: ColumnShorthand | ColumnOptions,
156
+ extra?: Omit<ColumnOptions, "type">,
157
+ ): ColumnOptions {
158
+ const base = _resolveBase(arg);
159
+ if (!extra) return base;
160
+ // Spread `base` last for type/cast so the shorthand keeps ownership of them, and never
161
+ // write an explicit `undefined` — under exactOptionalPropertyTypes that is a distinct,
162
+ // and invalid, value rather than an absent key.
163
+ const merged: ColumnOptions = { ...extra };
164
+ if (base.type !== undefined) merged.type = base.type;
165
+ if (extra.cast === undefined && base.cast !== undefined) merged.cast = base.cast;
166
+ return merged;
167
+ }
168
+
169
+ function _resolveBase(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
128
170
  if (arg === undefined) return { type: "string" };
129
171
  if (typeof arg === "string") {
130
172
  const a = arg as string;
@@ -243,8 +285,15 @@ type ColumnDecorator = (value: undefined, context: ClassFieldDecoratorContext) =
243
285
  export function column(): ColumnDecorator;
244
286
  export function column(type: ColumnShorthand): ColumnDecorator;
245
287
  export function column(options: ColumnOptions): ColumnDecorator;
246
- export function column(arg?: ColumnShorthand | ColumnOptions): ColumnDecorator {
247
- const options = resolveOptions(arg);
288
+ export function column(
289
+ type: ColumnShorthand,
290
+ options: Omit<ColumnOptions, "type">,
291
+ ): ColumnDecorator;
292
+ export function column(
293
+ arg?: ColumnShorthand | ColumnOptions,
294
+ extra?: Omit<ColumnOptions, "type">,
295
+ ): ColumnDecorator {
296
+ const options = resolveOptions(arg, extra);
248
297
  // The decorator BODY runs synchronously at definition time with the correct
249
298
  // `context.name` (the only thing Bun 1.3.x compiles reliably for field decorators).
250
299
  // We can't defer to a field initializer or addInitializer — Bun cross-wires those
@@ -46,7 +46,7 @@ interface TableConfig {
46
46
  * export class Post extends BaseModel { ... }
47
47
  *
48
48
  * Soft deletes are opt-in via the `SoftDeletes` mixin, not `@table`:
49
- * `class Post extends BaseModelWith(SoftDeletes) {}` — see /docs/orm/lifecycle.
49
+ * `class Post extends Model.using(SoftDeletes) {}` — see /docs/orm/lifecycle.
50
50
  */
51
51
  export interface TableDecoratorBuilder {
52
52
  /** Apply the decorator to a class constructor (called automatically by TS). */
@@ -77,7 +77,7 @@ export interface TableDecoratorBuilder {
77
77
  * ```
78
78
  *
79
79
  * Timestamps are **on by default** — use `.withoutTimestamps()` to opt out. Soft
80
- * deletes are **opt-in via the `SoftDeletes` mixin**: `extends BaseModelWith(SoftDeletes)`.
80
+ * deletes are **opt-in via the `SoftDeletes` mixin**: `extends Model.using(SoftDeletes)`.
81
81
  *
82
82
  * `@table` is the single, required way to configure a model: besides setting the
83
83
  * table name and options, it anchors the class's `@column`/relation registrations at