@zerotal/orm 1.1.0 → 1.4.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,11 +8,106 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.4.0] — 2026-08-10
12
+
13
+ ### Added
14
+
15
+ - **Encrypted columns.** A column can now hold ciphertext at rest and plaintext on
16
+ the model, keyed by `APP_KEY` with AES-256-GCM. Declare it per-column or as a
17
+ list; the two mean the same thing and resolve to the same cast:
18
+
19
+ ```ts
20
+ @column("encrypted", { nullable: true }) idNumber?: string;
21
+ @column("encrypted:json") medical?: MedicalInfo;
22
+
23
+ // …the same, spelled out — `encrypted` is a cast, not a storage type:
24
+ @column({ type: "text", nullable: true, cast: "encrypted" }) passportNumber?: string;
25
+
26
+ static encryptable = ["idNumber", "passportNumber"];
27
+ ```
28
+
29
+ `encrypted` and `encrypted:json` join the `@column("…")` shorthands, resolving
30
+ to `{ type: "text", cast: "encrypted" }` — so the storage type is right without
31
+ having to know that ciphertext outgrows its plaintext.
32
+
33
+ Encryption happens on the way to the database rather than to the instance, so —
34
+ unlike `hashable` — it is non-destructive: after `save()` the property still
35
+ holds what you assigned. `$dirty` compares plaintext, so an unchanged column is
36
+ not rewritten with a fresh IV on every unrelated save.
37
+
38
+ A column listed in `encryptable` whose `@column({ type })` is `json` encrypts as
39
+ `encrypted:json`, so it round-trips as the structure it was instead of reaching
40
+ the cipher as `"[object Object]"`.
41
+
42
+ **`where()` on an encrypted column throws** rather than returning nothing. The
43
+ bind path runs a column's cast over the search value, which would encrypt it
44
+ under a fresh IV and compare it against ciphertext written with a different one:
45
+ zero rows, no error, and a screen reading "no such client" for a client who is
46
+ right there. `EncryptedColumnError` says so and points at a blind index.
47
+
48
+ **A value the key cannot open fails the read**, naming the model, the column and
49
+ the two causes (a rotated `APP_KEY`, or plaintext that predates the cast).
50
+ Returning the ciphertext instead would put an unreadable value where the
51
+ application expects a real one — displayed, reported on, or re-encrypted by the
52
+ next save, which destroys the original.
53
+
54
+ `migrate:generate` and `synchronize()` widen an encrypted column to TEXT
55
+ whatever it was declared as. A payload is ~1.4× the plaintext plus 28 bytes, and
56
+ MySQL outside strict mode truncates rather than failing — a truncated payload
57
+ never decrypts, so the row would be destroyed silently at write time.
58
+
59
+ ## [1.3.0] — 2026-08-09
60
+
61
+ ### Changed — BREAKING
62
+
63
+ - **`BaseModelWith(...)` is replaced by the `Model.using(...)` static.** Mixin composition is now
64
+ a property of the base class rather than a helper shipped alongside it, so there is one idiom to
65
+ learn and nothing extra to import.
66
+
67
+ ```ts
68
+ // before
69
+ import { BaseModelWith } from "@zerotal/orm";
70
+ class User extends BaseModelWith(Authenticatable, Permissions, Roles) {}
71
+
72
+ // after
73
+ import { Model } from "@zerotal/orm";
74
+ class User extends Model.using(Authenticatable, Permissions, Roles) {}
75
+ ```
76
+
77
+ Run `bun run scripts/codemod-mixin-composition.ts` to rewrite call sites and imports.
78
+
79
+ How mixins are **authored** is unchanged — `<T extends Constructor>(Base: T) => class extends Base`
80
+ still works exactly as before, and every shipped mixin (`SoftDeletes`, `State`, `Authenticatable`,
81
+ `Roles`, `Permissions`, `Notifiable`, `Tenantable`, `Auditable`, …) keeps its signature. The
82
+ `Constructor` and `Mixin` types are still exported; `Compose` (the type of `Model.using`) joins
83
+ them. Mixin authors declaring columns still call `registerColumn` imperatively.
84
+
85
+ `with` was deliberately **not** used for this. It is reserved for the eager-load static
86
+ (`User.with("posts")`), the one conspicuous gap in the model's existing query-forwarder family
87
+ (`where`, `whereIn`, `orderBy`, `latest`, `first`, `paginate`, `find`, `all`, `count`, …).
88
+
89
+ ### Changed
90
+
91
+ - **`Model` is now the canonical name for the base class; `BaseModel` is the alias.** They are the
92
+ same class object and both remain exported, so no code breaks — but `class User extends Model {}`
93
+ is the documented form from here, mirroring Flow's `class PostsPage extends Component {}`.
94
+ `BaseModel` was previously the canonical name and `Model` an unused compat alias added in 1.0.2.
95
+
96
+ ### Added
97
+
98
+ - **`using` composes onto any class in the chain, not just the root.** An app-level base model can
99
+ now carry mixins without being flattened out of the prototype chain — `AppModel.using(SoftDeletes)`
100
+ keeps `AppModel` and its statics in the lineage. `BaseModelWith` hardcoded `BaseModel`, so this
101
+ previously required hand-nesting.
102
+ - **Composition chains.** The composed class carries `using` itself, so
103
+ `Model.using(a, b).using(c, d)` works past the 8-mixin overload set — which is why the overload
104
+ set shrank from 20 hand-written arities to 8 without losing any capability.
105
+
11
106
  ## [1.1.0] — 2026-08-08
12
107
 
13
108
  ### Fixed
14
109
 
15
- - **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.
110
+ - **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.
16
111
  - **`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.
17
112
  - **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`.
18
113
  - **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.
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.1.0",
3
+ "version": "1.4.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.1.0",
34
- "@zerotal/validator": "1.1.0"
33
+ "@zerotal/core": "1.4.0",
34
+ "@zerotal/validator": "1.4.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0"
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Encrypted columns — ciphertext at rest, plaintext on the model.
3
+ *
4
+ * The column stores an opaque AES-256-GCM payload keyed by `APP_KEY`; the model
5
+ * property holds the value you assigned. Encryption happens on the way to the
6
+ * database and decryption on the way back, so nothing in between — your code,
7
+ * validation, `$dirty` — has to know the column is encrypted.
8
+ *
9
+ * Two ways to declare one, and they compile to the same thing:
10
+ *
11
+ * ```ts
12
+ * class Client extends BaseModel {
13
+ * @column({ type: "text", nullable: true, cast: "encrypted" })
14
+ * idNumber?: string;
15
+ *
16
+ * // …or, for several columns at once:
17
+ * static encryptable = ["idNumber", "passportNumber"];
18
+ * }
19
+ * ```
20
+ *
21
+ * **The column must be `text`, not `string`.** A payload is roughly a third
22
+ * larger than its plaintext plus 28 bytes of IV and auth tag, so a 13-character
23
+ * ID number lands around 60 characters and a paragraph overflows a `VARCHAR(255)`
24
+ * that comfortably held it before.
25
+ *
26
+ * **You cannot query an encrypted column.** Every write draws a fresh IV, so the
27
+ * same value encrypts to different ciphertext each time and an equality match can
28
+ * never hit. `where()` on one throws rather than returning zero rows — see
29
+ * {@link EncryptedColumnError}. If you need lookup, keep a separate hashed column
30
+ * (a blind index) beside it and query that.
31
+ *
32
+ * **Decryption failure is fatal to the read**, deliberately. Returning the
33
+ * ciphertext instead would put an unreadable value where the application expects
34
+ * a real one — displayed to a user, written into a report, or re-encrypted on the
35
+ * next save, which destroys the original for good.
36
+ *
37
+ * @packageDocumentation
38
+ */
39
+
40
+ import { ZerotalError } from "@zerotal/core";
41
+ import { Crypt } from "@zerotal/core/security";
42
+
43
+ /** The cast names that mean "encrypt this column". */
44
+ export type EncryptedCastName = "encrypted" | "encrypted:json";
45
+
46
+ /** Raised for anything an encrypted column cannot do. */
47
+ export class EncryptedColumnError extends ZerotalError {
48
+ constructor(message: string, code: string, context?: Record<string, unknown>) {
49
+ super(message, code, 500, context);
50
+ }
51
+ }
52
+
53
+ /** Whether a resolved cast option is one of the encrypted ones. */
54
+ export function isEncryptedCast(cast: unknown): cast is EncryptedCastName {
55
+ return cast === "encrypted" || cast === "encrypted:json";
56
+ }
57
+
58
+ /**
59
+ * Model value → the ciphertext written to the column.
60
+ *
61
+ * @param label - `Model.column`, or just the column name, for error messages.
62
+ */
63
+ export function encryptColumn(value: unknown, cast: EncryptedCastName, label: string): unknown {
64
+ if (value === null || value === undefined) return value;
65
+
66
+ if (cast === "encrypted:json") return Crypt.encryptString(JSON.stringify(value));
67
+
68
+ if (typeof value !== "string") {
69
+ // Not coerced with String(). `42` would store as "42" and read back as the
70
+ // string "42" — the value's type silently changing between write and read,
71
+ // which is worse than refusing it, because nothing fails until something
72
+ // downstream compares it.
73
+ throw new EncryptedColumnError(
74
+ `[Zerotal] ${label} is cast "encrypted", which stores strings, but a ` +
75
+ `${Array.isArray(value) ? "array" : typeof value} was assigned. Use ` +
76
+ `cast: "encrypted:json" to encrypt a structured value — it round-trips the type.`,
77
+ "E_ENCRYPTED_COLUMN_NOT_A_STRING",
78
+ { column: label, received: typeof value },
79
+ );
80
+ }
81
+
82
+ return Crypt.encryptString(value);
83
+ }
84
+
85
+ /**
86
+ * Stored ciphertext → the value the model exposes.
87
+ *
88
+ * @param label - `Model.column`, for error messages.
89
+ * @throws {@link EncryptedColumnError} When the stored value is not ciphertext
90
+ * this `APP_KEY` can open.
91
+ */
92
+ export function decryptColumn(value: unknown, cast: EncryptedCastName, label: string): unknown {
93
+ if (value === null || value === undefined) return value;
94
+
95
+ let plain: string;
96
+ try {
97
+ plain = Crypt.decryptString(String(value));
98
+ } catch (cause) {
99
+ throw new EncryptedColumnError(
100
+ `[Zerotal] Could not decrypt ${label}. The column is cast "${cast}", so what is ` +
101
+ `stored has to be ciphertext this APP_KEY can open. Two things cause this: ` +
102
+ `APP_KEY changed since the row was written (decrypt with the old key and ` +
103
+ `re-save), or the column already held plaintext when the cast was added ` +
104
+ `(back-fill the existing rows before switching it on). The row cannot be read ` +
105
+ `until one of those is resolved.`,
106
+ "E_ENCRYPTED_COLUMN_UNREADABLE",
107
+ { column: label, cause: cause instanceof Error ? cause.message : String(cause) },
108
+ );
109
+ }
110
+
111
+ if (cast !== "encrypted:json") return plain;
112
+ try {
113
+ return JSON.parse(plain);
114
+ } catch {
115
+ // Decrypted cleanly, so the key is right and the bytes are intact — the
116
+ // column simply was not written as JSON. Says so, rather than reporting a
117
+ // key problem it does not have.
118
+ throw new EncryptedColumnError(
119
+ `[Zerotal] ${label} decrypted, but its contents are not JSON. The column is cast ` +
120
+ `"encrypted:json"; a column written under plain "encrypted" reads back with that.`,
121
+ "E_ENCRYPTED_COLUMN_NOT_JSON",
122
+ { column: label },
123
+ );
124
+ }
125
+ }
126
+
127
+ /** The error thrown when someone tries to filter on an encrypted column. */
128
+ export function encryptedQueryError(label: string): EncryptedColumnError {
129
+ return new EncryptedColumnError(
130
+ `[Zerotal] Cannot filter on ${label} — it is an encrypted column. Every write draws ` +
131
+ `a fresh IV, so the same value encrypts to different ciphertext each time and an ` +
132
+ `equality match can never hit. Keep a separate hashed lookup column (a blind index) ` +
133
+ `beside it and query that instead.`,
134
+ "E_ENCRYPTED_COLUMN_NOT_QUERYABLE",
135
+ { column: label },
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Resolve `static encryptable = [...]` down a prototype chain into cast entries.
141
+ *
142
+ * Declaring it is exactly equivalent to putting `cast: "encrypted"` on each of
143
+ * those columns, which is why it resolves to casts here rather than being handled
144
+ * separately: read, write, `$dirty` and the query guard then all see one thing.
145
+ *
146
+ * A `json` column resolves to `encrypted:json` on its own — the alternative is
147
+ * `String(someObject)` reaching the cipher as `"[object Object]"`.
148
+ *
149
+ * Entries union down the chain, so a base model marking a column encrypted keeps
150
+ * it encrypted in a subclass that lists its own.
151
+ *
152
+ * @param chain - The constructor chain, base-most first.
153
+ * @param columnType - Declared `@column({ type })` for a property, if any.
154
+ */
155
+ export function collectEncryptable(
156
+ chain: readonly object[],
157
+ columnType: (key: string) => string | undefined,
158
+ ): Record<string, EncryptedCastName> {
159
+ const out: Record<string, EncryptedCastName> = {};
160
+ for (const entry of chain) {
161
+ const keys = (entry as { encryptable?: string[] }).encryptable;
162
+ if (!keys) continue;
163
+ for (const key of keys) {
164
+ out[key] = columnType(key) === "json" ? "encrypted:json" : "encrypted";
165
+ }
166
+ }
167
+ return out;
168
+ }
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";
@@ -242,6 +244,11 @@ export type { DatabaseConfigShape } from "./config.ts";
242
244
  // Casts
243
245
  export { Cast, JsonCast, ArrayCast, json, objectOf, arrayOf } from "./casts/Cast.ts";
244
246
  export type { CastContract, CastMapper, CastField } from "./casts/Cast.ts";
247
+ // Encrypted columns — `cast: "encrypted"` / `static encryptable`. The error is
248
+ // exported so an app can catch an unreadable row (a rotated APP_KEY) and say
249
+ // something useful instead of 500ing.
250
+ export { EncryptedColumnError, isEncryptedCast } from "./casts/encrypted.ts";
251
+ export type { EncryptedCastName } from "./casts/encrypted.ts";
245
252
 
246
253
  // Framework events emitted by the ORM (subscribe via core's FrameworkEvents bus).
247
254
  export {
@@ -33,7 +33,16 @@ 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";
39
+ import {
40
+ collectEncryptable,
41
+ decryptColumn,
42
+ encryptColumn,
43
+ isEncryptedCast,
44
+ type EncryptedCastName,
45
+ } from "../casts/encrypted.ts";
37
46
  import { TransactionContext } from "../db/TransactionContext.ts";
38
47
  import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
39
48
  import type { WhereOperator, OrderDirection } from "../db/types.ts";
@@ -220,6 +229,7 @@ type StringCast =
220
229
  | "float"
221
230
  | "enum"
222
231
  | "immutable_datetime"
232
+ | EncryptedCastName
223
233
  | `decimal:${number}`;
224
234
  type CastOption = ColumnOptions["cast"];
225
235
 
@@ -232,6 +242,13 @@ function getCasts(ctor: Function): Record<string, CastOption> {
232
242
  current = Object.getPrototypeOf(current) as Function | null;
233
243
  }
234
244
  chain.reverse();
245
+ // `static encryptable` first, so an explicit cast on the same column still wins —
246
+ // spelling one out is the more specific statement of intent.
247
+ const colReg = columnsFor(ctor);
248
+ Object.assign(
249
+ merged,
250
+ collectEncryptable(chain, (key) => colReg?.get(key)?.type),
251
+ );
235
252
  for (const entry of chain) {
236
253
  const casts = (entry as { casts?: Record<string, CastOption> }).casts;
237
254
  if (casts) Object.assign(merged, casts);
@@ -239,8 +256,9 @@ function getCasts(ctor: Function): Record<string, CastOption> {
239
256
  return merged;
240
257
  }
241
258
 
242
- function applyCastGet(value: unknown, cast: StringCast): unknown {
259
+ function applyCastGet(value: unknown, cast: StringCast, label: string): unknown {
243
260
  if (value === null || value === undefined) return value;
261
+ if (isEncryptedCast(cast)) return decryptColumn(value, cast, label);
244
262
  const cstr = cast as unknown as string;
245
263
  if (cstr.startsWith("decimal:")) {
246
264
  const n = parseInt(cstr.slice(8), 10) || 0;
@@ -292,8 +310,9 @@ function tryParseJson(s: string): unknown {
292
310
  }
293
311
  }
294
312
 
295
- function applyCastSet(value: unknown, cast: StringCast): unknown {
313
+ function applyCastSet(value: unknown, cast: StringCast, label: string): unknown {
296
314
  if (value === null || value === undefined) return value;
315
+ if (isEncryptedCast(cast)) return encryptColumn(value, cast, label);
297
316
  const cstr = cast as unknown as string;
298
317
  if (cstr.startsWith("decimal:")) {
299
318
  const n = parseInt(cstr.slice(8), 10) || 0;
@@ -360,6 +379,7 @@ function _serializeForWrite(
360
379
  val: unknown,
361
380
  casts: Record<string, CastOption>,
362
381
  colReg: Map<string, ColumnOptions> | null,
382
+ model?: string,
363
383
  ): unknown {
364
384
  const colMeta = colReg?.get(key);
365
385
  const castOpt = casts[key] ?? colMeta?.cast;
@@ -368,7 +388,7 @@ function _serializeForWrite(
368
388
  if (castOpt && typeof castOpt === "object" && castOpt.set) {
369
389
  serializedVal = castOpt.set(val);
370
390
  } else if (typeof castOpt === "string") {
371
- serializedVal = applyCastSet(val, castOpt);
391
+ serializedVal = applyCastSet(val, castOpt, model ? `${model}.${key}` : key);
372
392
  } else if (colType === "boolean" && val !== null && val !== undefined) {
373
393
  serializedVal = val ? 1 : 0;
374
394
  } else if (colType === "json" && val !== null) {
@@ -567,6 +587,37 @@ export class BaseModel {
567
587
  */
568
588
  declare readonly __isZerotalModel: true;
569
589
 
590
+ /**
591
+ * Compose one or more model mixins onto this class, folding them left-to-right, so reusable
592
+ * model behaviour (auth contract, roles, permissions, soft deletes, tenancy, …) stacks flat
593
+ * instead of nesting.
594
+ *
595
+ * @remarks
596
+ * Each mixin receives the accumulated base and returns an extended class, so this class's full
597
+ * static surface (`query()`, `find()`, `create()`, scopes, …) and every mixin's instance and
598
+ * static members flow through to the composed class — fully type-checked. Prefer this over
599
+ * hand-nesting mixins (`Roles(Permissions(AuthUser))`), which reads inside-out and repeats the
600
+ * base.
601
+ *
602
+ * `using` composes onto whatever class it is called on, so it also works on an intermediate
603
+ * model base, and the composed class carries `using` itself, so `Model.using(a, b).using(c)`
604
+ * chains past the 8-mixin overload set.
605
+ *
606
+ * Mixin authors declaring columns must call {@link registerColumn} imperatively — the `@column`
607
+ * decorator cannot run inside a returned class expression.
608
+ *
609
+ * @param mixins - Mixin factories applied in order; each receives the class the previous one produced.
610
+ * @returns A model class extending this one with every mixin applied.
611
+ *
612
+ * @example
613
+ * ```ts
614
+ * class User extends Model.using(Authenticatable, Permissions, Roles) {}
615
+ * ```
616
+ *
617
+ * @category Composition
618
+ */
619
+ static using: Compose = _compose;
620
+
570
621
  /**
571
622
  * Database table this model maps to. Usually set for you by the `@table("…")`
572
623
  * decorator; assign directly to override.
@@ -800,6 +851,42 @@ export class BaseModel {
800
851
  */
801
852
  static hashable?: string[];
802
853
 
854
+ /**
855
+ * Columns encrypted at rest with AES-256-GCM under `APP_KEY`, and decrypted
856
+ * transparently on read. Shorthand for putting `cast: "encrypted"` on each one.
857
+ *
858
+ * Unlike {@link hashable} this is reversible and non-destructive: the model
859
+ * property still holds the value you assigned after a `save()`, because the
860
+ * encryption happens on the way to the database rather than to the instance.
861
+ * `$dirty` therefore compares plaintext, and an unchanged column is not
862
+ * rewritten with a new IV on every save.
863
+ *
864
+ * A `json` column in this list encrypts as `encrypted:json`, so it round-trips
865
+ * as the structure it was rather than as `"[object Object]"`.
866
+ *
867
+ * @example
868
+ * ```ts
869
+ * class Client extends BaseModel {
870
+ * static encryptable = ["idNumber", "passportNumber"];
871
+ *
872
+ * // TEXT, not VARCHAR — a payload outgrows its plaintext.
873
+ * @column({ type: "text", nullable: true }) idNumber?: string;
874
+ * @column({ type: "text", nullable: true }) passportNumber?: string;
875
+ * }
876
+ * ```
877
+ *
878
+ * @remarks
879
+ * Encrypted columns cannot be filtered, grouped or usefully sorted — every
880
+ * write draws a fresh IV, so the ciphertext for a given value never repeats.
881
+ * `where()` on one throws rather than quietly matching nothing. For lookup,
882
+ * keep a hashed blind-index column beside it. Add these to {@link hidden} too
883
+ * if the model is serialized to a client: decryption puts the real value back
884
+ * on the instance, and `toJSON()` will happily include it.
885
+ *
886
+ * @category Persistence
887
+ */
888
+ static encryptable?: string[];
889
+
803
890
  /**
804
891
  * Register an observer class for this model.
805
892
  * The observer's lifecycle methods (creating, created, updating, …) are
@@ -1447,7 +1534,7 @@ export class BaseModel {
1447
1534
  const row: Record<string, unknown> = {};
1448
1535
  for (const [key, val] of Object.entries(rec as Record<string, unknown>)) {
1449
1536
  if (key.startsWith("_")) continue;
1450
- row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1537
+ row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg, ModelClass.name);
1451
1538
  }
1452
1539
  if (useTs) {
1453
1540
  row["created_at"] = now;
@@ -1529,7 +1616,7 @@ export class BaseModel {
1529
1616
  const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1530
1617
  const r: Record<string, unknown> = {};
1531
1618
  for (const [key, val] of Object.entries(data as Record<string, unknown>)) {
1532
- r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1619
+ r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg, this.name);
1533
1620
  }
1534
1621
  return r;
1535
1622
  });
@@ -1670,7 +1757,7 @@ export class BaseModel {
1670
1757
  // value is readable straight after save() without a reload.
1671
1758
  self[key] = effective;
1672
1759
  }
1673
- r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg);
1760
+ r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg, ModelClass.name);
1674
1761
  }
1675
1762
  if (ModelClass.timestamps) {
1676
1763
  const now = _serializeDate(new Date());
@@ -1752,7 +1839,11 @@ export class BaseModel {
1752
1839
  if (Object.keys(dirty).length > 0) {
1753
1840
  const entries = _writeDialect.run(dialect, () =>
1754
1841
  Object.entries(dirty).map(
1755
- ([k, v]) => [toSnake(k), _serializeForWrite(k, v, casts, colReg)] as [string, unknown],
1842
+ ([k, v]) =>
1843
+ [toSnake(k), _serializeForWrite(k, v, casts, colReg, ModelClass.name)] as [
1844
+ string,
1845
+ unknown,
1846
+ ],
1756
1847
  ),
1757
1848
  );
1758
1849
  const segs: Seg[] = [`UPDATE ${ModelClass.table} SET `];
@@ -2434,7 +2525,7 @@ function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
2434
2525
  finalVal = castObj.get(rawVal);
2435
2526
  } else if (typeof cast === "string") {
2436
2527
  // Explicit shorthand cast ('boolean', 'json', 'date', etc.)
2437
- finalVal = applyCastGet(rawVal, cast);
2528
+ finalVal = applyCastGet(rawVal, cast, `${ModelClass.name}.${propKey}`);
2438
2529
  } else if (colType === "boolean" && rawVal !== null && rawVal !== undefined) {
2439
2530
  // Auto-cast based on @column({ type: 'boolean' }) — SQLite stores 0/1
2440
2531
  finalVal = rawVal === 1 || rawVal === "1" || rawVal === true;
@@ -2530,5 +2621,7 @@ function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
2530
2621
  (inst as unknown as { _original: Record<string, unknown> })._original = orig;
2531
2622
  }
2532
2623
 
2533
- // Backward-compat alias (index.ts exports `Model`)
2624
+ // `Model` is the canonical name at the declaration site — `class User extends Model.using(…)`
2625
+ // mirrors Flow's `class PostsPage extends Component.using(…)`. `BaseModel` remains exported as
2626
+ // an alias (same class object) for code that references the base class by that name.
2534
2627
  export { BaseModel as Model };
@@ -6,6 +6,7 @@ import {
6
6
  toSnakeColumn as _toSnakeColumn,
7
7
  ctorChain,
8
8
  } from "../support/identifiers.ts";
9
+ import { collectEncryptable, encryptedQueryError, isEncryptedCast } from "../casts/encrypted.ts";
9
10
  import type {
10
11
  PaginateResult,
11
12
  SimplePaginateResult,
@@ -27,6 +28,14 @@ type CastOption = ColumnOptions["cast"];
27
28
 
28
29
  function _getCasts(ctor: Function): Record<string, CastOption> {
29
30
  const merged: Record<string, CastOption> = {};
31
+ const colReg = columnsFor(ctor);
32
+ // Mirrors getCasts() in BaseModel: `static encryptable` resolves to casts, and an
33
+ // explicit cast on the same column wins. Without this the guard below cannot see
34
+ // a column declared encrypted through the list form.
35
+ Object.assign(
36
+ merged,
37
+ collectEncryptable(ctorChain(ctor), (key) => colReg?.get(key)?.type),
38
+ );
30
39
  for (const entry of ctorChain(ctor)) {
31
40
  const casts = (entry as { casts?: Record<string, CastOption> }).casts;
32
41
  if (casts) Object.assign(merged, casts);
@@ -1245,6 +1254,15 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
1245
1254
  const castOpt = casts[rawKey] ?? casts[camelKey] ?? colMeta?.cast;
1246
1255
  const colType = colMeta?.type;
1247
1256
 
1257
+ // Before anything binds: an encrypted column cannot be compared. Running the
1258
+ // cast's set() here would encrypt the search term under a fresh IV, producing
1259
+ // ciphertext that cannot equal what is stored — a query that always returns
1260
+ // nothing and never says why. Same failure the created_at note below describes,
1261
+ // and permanent rather than occasional, so it is refused outright.
1262
+ if (isEncryptedCast(castOpt)) {
1263
+ throw encryptedQueryError(`${this._ModelClass.name}.${camelKey}`);
1264
+ }
1265
+
1248
1266
  if (operator === "in" || operator === "not in") {
1249
1267
  if (Array.isArray(value)) {
1250
1268
  return value.map((v) => this._coerceWhereValue(column, v));
@@ -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
  *