@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 +96 -1
- package/README.md +3 -3
- package/package.json +3 -3
- package/src/casts/encrypted.ts +168 -0
- package/src/index.ts +18 -11
- package/src/model/BaseModel.ts +102 -9
- package/src/model/ModelQueryBuilder.ts +18 -0
- package/src/model/SoftDeletes.ts +3 -3
- package/src/model/State.ts +3 -3
- package/src/model/decorators/column.ts +17 -1
- package/src/model/decorators/table.ts +2 -2
- package/src/model/mixins.ts +126 -501
- package/src/schema/ModelInspector.ts +30 -3
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
|
|
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 {
|
|
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
|
|
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** — `
|
|
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.
|
|
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.
|
|
34
|
-
"@zerotal/validator": "1.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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 {
|
|
21
|
+
* import { Model, column, hasMany, type HasMany } from "@zerotal/orm";
|
|
22
22
|
* import { Post } from "./Post.ts";
|
|
23
23
|
*
|
|
24
|
-
* export class User extends
|
|
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
|
-
|
|
74
|
-
export {
|
|
75
|
-
|
|
76
|
-
//
|
|
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 `
|
|
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
|
-
//
|
|
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 {
|
package/src/model/BaseModel.ts
CHANGED
|
@@ -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]) =>
|
|
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
|
-
//
|
|
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));
|
package/src/model/SoftDeletes.ts
CHANGED
|
@@ -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 {
|
|
7
|
+
// import { Model, SoftDeletes } from "@zerotal/orm";
|
|
8
8
|
//
|
|
9
9
|
// @table("posts")
|
|
10
|
-
// class Post extends
|
|
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
|
|
50
|
+
* class Post extends Model.using(SoftDeletes) {
|
|
51
51
|
* @column() title!: string;
|
|
52
52
|
* }
|
|
53
53
|
*
|