@zerotal/orm 1.3.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,6 +8,54 @@ 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
+
11
59
  ## [1.3.0] — 2026-08-09
12
60
 
13
61
  ### Changed — BREAKING
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.3.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.3.0",
34
- "@zerotal/validator": "1.3.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
@@ -244,6 +244,11 @@ export type { DatabaseConfigShape } from "./config.ts";
244
244
  // Casts
245
245
  export { Cast, JsonCast, ArrayCast, json, objectOf, arrayOf } from "./casts/Cast.ts";
246
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";
247
252
 
248
253
  // Framework events emitted by the ORM (subscribe via core's FrameworkEvents bus).
249
254
  export {
@@ -36,6 +36,13 @@ import { installReactiveAccessors, type ColumnOptions } from "./decorators/colum
36
36
  import { _compose } from "./mixins.ts";
37
37
  import type { Compose } from "./mixins.ts";
38
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";
39
46
  import { TransactionContext } from "../db/TransactionContext.ts";
40
47
  import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
41
48
  import type { WhereOperator, OrderDirection } from "../db/types.ts";
@@ -222,6 +229,7 @@ type StringCast =
222
229
  | "float"
223
230
  | "enum"
224
231
  | "immutable_datetime"
232
+ | EncryptedCastName
225
233
  | `decimal:${number}`;
226
234
  type CastOption = ColumnOptions["cast"];
227
235
 
@@ -234,6 +242,13 @@ function getCasts(ctor: Function): Record<string, CastOption> {
234
242
  current = Object.getPrototypeOf(current) as Function | null;
235
243
  }
236
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
+ );
237
252
  for (const entry of chain) {
238
253
  const casts = (entry as { casts?: Record<string, CastOption> }).casts;
239
254
  if (casts) Object.assign(merged, casts);
@@ -241,8 +256,9 @@ function getCasts(ctor: Function): Record<string, CastOption> {
241
256
  return merged;
242
257
  }
243
258
 
244
- function applyCastGet(value: unknown, cast: StringCast): unknown {
259
+ function applyCastGet(value: unknown, cast: StringCast, label: string): unknown {
245
260
  if (value === null || value === undefined) return value;
261
+ if (isEncryptedCast(cast)) return decryptColumn(value, cast, label);
246
262
  const cstr = cast as unknown as string;
247
263
  if (cstr.startsWith("decimal:")) {
248
264
  const n = parseInt(cstr.slice(8), 10) || 0;
@@ -294,8 +310,9 @@ function tryParseJson(s: string): unknown {
294
310
  }
295
311
  }
296
312
 
297
- function applyCastSet(value: unknown, cast: StringCast): unknown {
313
+ function applyCastSet(value: unknown, cast: StringCast, label: string): unknown {
298
314
  if (value === null || value === undefined) return value;
315
+ if (isEncryptedCast(cast)) return encryptColumn(value, cast, label);
299
316
  const cstr = cast as unknown as string;
300
317
  if (cstr.startsWith("decimal:")) {
301
318
  const n = parseInt(cstr.slice(8), 10) || 0;
@@ -362,6 +379,7 @@ function _serializeForWrite(
362
379
  val: unknown,
363
380
  casts: Record<string, CastOption>,
364
381
  colReg: Map<string, ColumnOptions> | null,
382
+ model?: string,
365
383
  ): unknown {
366
384
  const colMeta = colReg?.get(key);
367
385
  const castOpt = casts[key] ?? colMeta?.cast;
@@ -370,7 +388,7 @@ function _serializeForWrite(
370
388
  if (castOpt && typeof castOpt === "object" && castOpt.set) {
371
389
  serializedVal = castOpt.set(val);
372
390
  } else if (typeof castOpt === "string") {
373
- serializedVal = applyCastSet(val, castOpt);
391
+ serializedVal = applyCastSet(val, castOpt, model ? `${model}.${key}` : key);
374
392
  } else if (colType === "boolean" && val !== null && val !== undefined) {
375
393
  serializedVal = val ? 1 : 0;
376
394
  } else if (colType === "json" && val !== null) {
@@ -833,6 +851,42 @@ export class BaseModel {
833
851
  */
834
852
  static hashable?: string[];
835
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
+
836
890
  /**
837
891
  * Register an observer class for this model.
838
892
  * The observer's lifecycle methods (creating, created, updating, …) are
@@ -1480,7 +1534,7 @@ export class BaseModel {
1480
1534
  const row: Record<string, unknown> = {};
1481
1535
  for (const [key, val] of Object.entries(rec as Record<string, unknown>)) {
1482
1536
  if (key.startsWith("_")) continue;
1483
- row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1537
+ row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg, ModelClass.name);
1484
1538
  }
1485
1539
  if (useTs) {
1486
1540
  row["created_at"] = now;
@@ -1562,7 +1616,7 @@ export class BaseModel {
1562
1616
  const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1563
1617
  const r: Record<string, unknown> = {};
1564
1618
  for (const [key, val] of Object.entries(data as Record<string, unknown>)) {
1565
- r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1619
+ r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg, this.name);
1566
1620
  }
1567
1621
  return r;
1568
1622
  });
@@ -1703,7 +1757,7 @@ export class BaseModel {
1703
1757
  // value is readable straight after save() without a reload.
1704
1758
  self[key] = effective;
1705
1759
  }
1706
- r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg);
1760
+ r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg, ModelClass.name);
1707
1761
  }
1708
1762
  if (ModelClass.timestamps) {
1709
1763
  const now = _serializeDate(new Date());
@@ -1785,7 +1839,11 @@ export class BaseModel {
1785
1839
  if (Object.keys(dirty).length > 0) {
1786
1840
  const entries = _writeDialect.run(dialect, () =>
1787
1841
  Object.entries(dirty).map(
1788
- ([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
+ ],
1789
1847
  ),
1790
1848
  );
1791
1849
  const segs: Seg[] = [`UPDATE ${ModelClass.table} SET `];
@@ -2467,7 +2525,7 @@ function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
2467
2525
  finalVal = castObj.get(rawVal);
2468
2526
  } else if (typeof cast === "string") {
2469
2527
  // Explicit shorthand cast ('boolean', 'json', 'date', etc.)
2470
- finalVal = applyCastGet(rawVal, cast);
2528
+ finalVal = applyCastGet(rawVal, cast, `${ModelClass.name}.${propKey}`);
2471
2529
  } else if (colType === "boolean" && rawVal !== null && rawVal !== undefined) {
2472
2530
  // Auto-cast based on @column({ type: 'boolean' }) — SQLite stores 0/1
2473
2531
  finalVal = rawVal === 1 || rawVal === "1" || rawVal === true;
@@ -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));
@@ -29,6 +29,8 @@ export { columnRegistry };
29
29
  * | `"date"` | `{ type: "datetime", cast: "date" }` |
30
30
  * | `"json"` | `{ type: "json", cast: "json" }` |
31
31
  * | `"array"` | `{ type: "json", cast: "array" }` |
32
+ * | `"encrypted"` | `{ type: "text", cast: "encrypted" }` |
33
+ * | `"encrypted:json"` | `{ type: "text", cast: "encrypted:json" }` |
32
34
  */
33
35
  export type ColumnShorthand =
34
36
  | "string"
@@ -40,7 +42,9 @@ export type ColumnShorthand =
40
42
  | "datetime"
41
43
  | "date"
42
44
  | "json"
43
- | "array";
45
+ | "array"
46
+ | "encrypted"
47
+ | "encrypted:json";
44
48
 
45
49
  /**
46
50
  * Full option object accepted by `@column({ ... })`.
@@ -105,6 +109,12 @@ export interface ColumnOptions {
105
109
  * - 'integer' — parseInt on both read and write
106
110
  * - 'float' — parseFloat on both read and write
107
111
  * - 'enum' — pass-through; pairs with `enumValues` for TS enum columns
112
+ * - 'encrypted' — AES-256-GCM at rest under `APP_KEY`, plaintext on the model
113
+ * - 'encrypted:json' — the same, for a structured value (stringified, then encrypted)
114
+ *
115
+ * Encrypted columns need `type: "text"` (a payload outgrows the plaintext) and
116
+ * cannot be filtered on — `where()` against one throws, because a fresh IV per
117
+ * write means the ciphertext never repeats. See `casts/encrypted.ts`.
108
118
  */
109
119
  cast?:
110
120
  | "datetime"
@@ -116,6 +126,8 @@ export interface ColumnOptions {
116
126
  | "float"
117
127
  | "enum"
118
128
  | "immutable_datetime"
129
+ | "encrypted"
130
+ | "encrypted:json"
119
131
  | `decimal:${number}`
120
132
  | {
121
133
  get?: (dbValue: unknown) => unknown;
@@ -140,6 +152,10 @@ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
140
152
  date: { type: "datetime", cast: "date" },
141
153
  json: { type: "json", cast: "json" },
142
154
  array: { type: "json", cast: "array" },
155
+ // TEXT, not string: the stored payload is ~1.4× the plaintext plus 28 bytes of
156
+ // IV and auth tag, so a VARCHAR that held the value will not hold its ciphertext.
157
+ encrypted: { type: "text", cast: "encrypted" },
158
+ "encrypted:json": { type: "text", cast: "encrypted:json" },
143
159
  };
144
160
 
145
161
  /**
@@ -1,6 +1,8 @@
1
1
  import path from "node:path";
2
2
  import type { ColumnOptions } from "../model/decorators/column.ts";
3
3
  import { columnRegistry, columnsFor } from "../model/decorators/_metadata.ts";
4
+ import { ctorChain } from "../support/identifiers.ts";
5
+ import { isEncryptedCast } from "../casts/encrypted.ts";
4
6
 
5
7
  // ── Model schema descriptor ───────────────────────────────────────────────────
6
8
 
@@ -54,12 +56,22 @@ function collectColumns(ctor: Function): Map<string, ColumnOptions> | null {
54
56
  return columnsFor(ctor);
55
57
  }
56
58
 
57
- function toModelColumns(fields: Map<string, ColumnOptions>): ModelColumn[] {
59
+ function toModelColumns(
60
+ fields: Map<string, ColumnOptions>,
61
+ encrypted: ReadonlySet<string>,
62
+ ): ModelColumn[] {
58
63
  const columns: ModelColumn[] = [];
59
64
  for (const [name, opts] of fields.entries()) {
60
65
  columns.push({
61
66
  name,
62
- type: opts.type,
67
+ // An encrypted column is generated as TEXT whatever it was declared as. The
68
+ // stored payload is the plaintext plus 28 bytes of IV and auth tag, base64'd —
69
+ // about 1.4× longer — so a VARCHAR(255) that comfortably held the value no
70
+ // longer holds its ciphertext. MySQL outside strict mode truncates rather than
71
+ // failing, and a truncated payload will not decrypt: the row is lost, quietly,
72
+ // at write time. The generated migration says `table.text(...)`, so the
73
+ // widening is visible in review rather than only here.
74
+ type: encrypted.has(name) ? "text" : opts.type,
63
75
  nullable: opts.nullable ?? false,
64
76
  primary: opts.primary ?? false,
65
77
  default: opts.default,
@@ -70,6 +82,21 @@ function toModelColumns(fields: Map<string, ColumnOptions>): ModelColumn[] {
70
82
  return columns;
71
83
  }
72
84
 
85
+ /** Columns encrypted by either route — `cast: "encrypted…"` or `static encryptable`. */
86
+ function encryptedColumns(
87
+ chain: readonly object[],
88
+ fields: Map<string, ColumnOptions>,
89
+ ): Set<string> {
90
+ const names = new Set<string>();
91
+ for (const [name, opts] of fields.entries()) {
92
+ if (isEncryptedCast(opts.cast)) names.add(name);
93
+ }
94
+ for (const entry of chain) {
95
+ for (const key of (entry as { encryptable?: string[] }).encryptable ?? []) names.add(key);
96
+ }
97
+ return names;
98
+ }
99
+
73
100
  // ── ModelInspector ────────────────────────────────────────────────────────────
74
101
 
75
102
  /**
@@ -133,7 +160,7 @@ export const ModelInspector = {
133
160
  primaryKey: (M["primaryKey"] as string | undefined) ?? "id",
134
161
  timestamps: (M["timestamps"] as boolean | undefined) ?? true,
135
162
  softDeletes: (M["softDeletes"] as boolean | undefined) ?? false,
136
- columns: toModelColumns(fields),
163
+ columns: toModelColumns(fields, encryptedColumns(ctorChain(ctor), fields)),
137
164
  };
138
165
  },
139
166
  };