@zerotal/arch 1.7.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,663 @@
1
+ ---
2
+ title: ORM
3
+ description: Map model classes to database tables with an Active Record layer over Bun's native SQL client.
4
+ ---
5
+
6
+ # ORM
7
+
8
+ Zerotal's ORM is an Active Record layer built on top of Bun's native SQL client. Each model class maps to a database table, columns are declared with decorators, and queries read fluently in TypeScript. The same model code runs on SQLite, PostgreSQL, and MySQL.
9
+
10
+ - [Casts & Mutators](/docs/orm/casts) — column types, custom casts, reactive JSON, the `static casts` map.
11
+ - [Queries](/docs/orm/queries) — the query builder, instance methods, scopes, and pagination.
12
+ - [Relationships](/docs/orm/relationships) — `belongsTo`/`hasMany`/etc., eager loading, and pivot operations.
13
+ - [Serialization](/docs/orm/serialization) — `hidden`/`visible`/`appends` and JSON output.
14
+ - [Lifecycle & Events](/docs/orm/lifecycle) — hooks, observers, state machines, `dispatchesEvents`, pruning.
15
+ - [Migrations](/docs/migrations) — evolving your tables as models change.
16
+ - [Seeding](/docs/seeding) — populating tables with factory and seeder data.
17
+
18
+ > **Note** — Working with raw SQL — transactions, the `DB` query builder, replicas, multiple connections,
19
+ > and the events the database emits — lives in [Database](/docs/database). The ORM is for
20
+ > models; the raw layer is for everything beneath them. Date/time values use [Carbon](/docs/carbon).
21
+
22
+ ## Getting Started
23
+
24
+ ```bash
25
+ # in your project root
26
+ bun add @zerotal/orm
27
+ ```
28
+
29
+ ## Register the provider
30
+
31
+ Add `DatabaseProvider` to the providers array in `bootstrap/providers.ts`:
32
+
33
+ ```typescript
34
+ // bootstrap/providers.ts
35
+ import { DatabaseProvider } from "@zerotal/orm";
36
+
37
+ const providers = [
38
+ // …your other providers
39
+ DatabaseProvider,
40
+ ];
41
+
42
+ export default providers;
43
+ ```
44
+
45
+ Registering the provider switches on the following (in lifecycle order):
46
+
47
+ - `onRegister` — binds the `db` connection as a lazy singleton, registers model/observer auto-discovery and implicit route-model binding, and wires the connection resolver.
48
+ - `onBooting` — opens the connection, detects the SQL dialect, bridges `dispatchesEvents` to the app event bus, and registers the validator's `unique()`/`exists()` rules.
49
+ - `onBooted` — enables N+1 query detection outside production and registers the `migrate`, `make:model`, `db:seed`, and related commands.
50
+ - `onStopping` — closes the connection so nothing leaks between boots or test suites.
51
+
52
+ ## Configuration
53
+
54
+ Create `config/database.ts`. Use the `DatabaseConfig()` helper so every field stays type-checked while literal values stay inferred:
55
+
56
+ ```typescript
57
+ // config/database.ts
58
+ import { DatabaseConfig } from "@zerotal/orm";
59
+ import { env } from "zerotal";
60
+
61
+ export default DatabaseConfig({
62
+ driver: env("DB_DRIVER", "sqlite"), // 'sqlite' | 'postgres' | 'mysql'
63
+ url: env("DATABASE_URL", "./database/db.sqlite"),
64
+
65
+ // PostgreSQL: 'postgres://user:pass@localhost:5432/mydb'
66
+ // MySQL: 'mysql://user:pass@localhost:3306/mydb'
67
+
68
+ replicas: [], // optional read-replica URLs; reads round-robin, writes hit primary
69
+
70
+ pool: {
71
+ max: env("DB_POOL_MAX", 10), // max connections (postgres/mysql)
72
+ idleTimeout: env("DB_POOL_IDLE_TIMEOUT", 30), // seconds before idle close
73
+ },
74
+
75
+ sqlite: {
76
+ path: env("DB_SQLITE_PATH", "./database/db.sqlite"), // ':memory:' for in-memory
77
+ },
78
+ });
79
+ ```
80
+
81
+ | Field | Required | Default | Description |
82
+ | ------------------ | -------- | ------------------------ | -------------------------------------------------------------------------------------- |
83
+ | `driver` | yes | `"sqlite"` | Database driver: `"sqlite"`, `"postgres"`, or `"mysql"`. |
84
+ | `url` | yes | `"./database/db.sqlite"` | Connection URL (or SQLite file path / `:memory:`). |
85
+ | `replicas` | no | `[]` | Read-replica URLs. Reads round-robin to replicas; writes and transactions hit primary. |
86
+ | `pool.max` | no | `10` | Maximum pool connections (PostgreSQL / MySQL only). |
87
+ | `pool.idleTimeout` | no | `30` | Seconds an idle connection is kept before closing. |
88
+ | `sqlite.path` | yes | `"./database/db.sqlite"` | SQLite file path. Use `":memory:"` for an in-memory database. |
89
+ | `synchronize` | no | `false` | Auto-sync the schema to your models at boot. Hard-off in production. See note below. |
90
+
91
+ > **Danger** — For SQLite, do not use a `sqlite://` protocol prefix in `url`. Bun's native
92
+ > SQLite driver expects a raw file path or `:memory:`.
93
+
94
+ > **Warning** — `synchronize: true` performs additive schema sync (creates missing tables and
95
+ > columns) at boot and is forced off in production. Set `{ enabled: true, disruptive: true }` to
96
+ > also DROP columns no model declares anymore — this destroys their data, so keep it local/test only.
97
+
98
+ ## Defining models
99
+
100
+ ### @table decorator
101
+
102
+ Every model is configured with the `@table()` decorator. It accepts a fluent chain or an options object — both styles are equivalent:
103
+
104
+ ```typescript
105
+ // app/models/Post.ts
106
+ import { Model, column, table } from "@zerotal/orm";
107
+
108
+ // Fluent chain (recommended for readability)
109
+ @(table("posts").withTimestamps())
110
+ export class Post extends Model {
111
+ @column("string") title!: string;
112
+ @column("text") body!: string;
113
+ @column("integer") views!: number;
114
+ }
115
+
116
+ // Options object — same result
117
+ @table("posts", { timestamps: true })
118
+ export class Post extends Model {
119
+ /* … */
120
+ }
121
+
122
+ // Override the primary key
123
+ @(table("users").primaryKey("user_id"))
124
+ export class User extends Model {
125
+ /* … */
126
+ }
127
+ ```
128
+
129
+ **Available chain methods and options:**
130
+
131
+ | Chain method | Option (`@table(name, {…})`) | Effect |
132
+ | ---------------------- | ---------------------------- | ------------------------------------------------------ |
133
+ | `.withTimestamps()` | `timestamps: true` | Enables `created_at` / `updated_at` (default on). |
134
+ | `.withoutTimestamps()` | `timestamps: false` | Disables automatic timestamp management. |
135
+ | `.primaryKey("col")` | `primaryKey: "col"` | Changes the primary key column name (default: `"id"`). |
136
+
137
+ > **Chain methods need the outer parentheses.** Decorator syntax allows a call at the end
138
+ > of the chain, not in the middle of it, so `@table("x").withoutTimestamps()` is a **parse
139
+ > error** — `Expected "class" but found "."`. Write `@(table("x").withoutTimestamps())`, or
140
+ > use the options object, which needs no parentheses. Plain `@table("x")` is fine as-is.
141
+
142
+ Timestamps are **on by default**, so `@table("ledger")` alone still writes `created_at` and
143
+ `updated_at`. For an append-only table whose migration creates neither column, say so —
144
+ otherwise the first save fails with `table ledger has no column named updated_at`:
145
+
146
+ ```typescript
147
+ @(table("ledger").withoutTimestamps())
148
+ export class LedgerEntry extends Model {
149
+ /* … */
150
+ }
151
+ ```
152
+
153
+ > **Note** — **Auto-discovery:** Models under `app/models/` don't need `@table` — they're auto-registered at boot with a conventional table name (`pluralize(snake(ClassName))`, e.g. `Post` → `posts`). Use `@table` only to override the name or options.
154
+ >
155
+ > **Packages and tests:** Models defined inside packages or inline in test files always need `@table` (or `registerModel(Class)`). `@table` is the definition-time anchor that registers the queued `@column`/relation fields — without it, those fields won't register.
156
+ >
157
+ > **Subclasses that add columns** also need their own `@table`, using the same table name for single-table inheritance:
158
+ >
159
+ > ```typescript
160
+ > // app/models/Admin.ts
161
+ > @table("users")
162
+ > class Admin extends User {
163
+ > @column("integer") level!: number; // ← won't register without @table on Admin
164
+ > }
165
+ > ```
166
+
167
+ > **Tip** — Soft deletes are **not** a `@table` option. Opt in per model with the `SoftDeletes`
168
+ > mixin: `class Post extends Model.using(SoftDeletes) {}`. It adds `deletedAt`, `restore()`,
169
+ > `forceDelete()`, `trashed()`, and the `withTrashed()` / `onlyTrashed()` scopes. See
170
+ > [Lifecycle & Events](/docs/orm/lifecycle).
171
+
172
+ ### @column decorator
173
+
174
+ Declare typed columns. Accepts a shorthand cast string or a full options object:
175
+
176
+ ```typescript
177
+ // in a model class body
178
+ import { column } from "@zerotal/orm";
179
+
180
+ // Shorthand (most common)
181
+ @column("string") name!: string;
182
+ @column("integer") views!: number;
183
+ @column("boolean") active!: boolean;
184
+ @column("datetime") createdAt!: Carbon;
185
+ @column("date") birthday?: Date; // native Date — not Carbon; see below
186
+ @column("json") meta!: Record<string, unknown>;
187
+ @column("array") tags!: string[];
188
+ @column("float") score!: number;
189
+ @column("text") bio?: string;
190
+ @column("encrypted") idNumber?: string; // ciphertext at rest, plaintext here
191
+
192
+ // Shorthand + options — the shorthand keeps its type and cast
193
+ @column("string", { nullable: true }) nickname?: string | null;
194
+ @column("integer", { nullable: true, default: 0 }) retries?: number | null;
195
+
196
+ // Full options object
197
+ @column({ type: "datetime", cast: "datetime" }) publishedAt?: Carbon;
198
+ @column({ type: "number", cast: "decimal:2" }) price!: string; // toFixed() — a string
199
+ ```
200
+
201
+ Shorthands map to: `string`, `text`, `integer`, `number`, `float`, `boolean`, `datetime`, `date`, `json`, `array`, `encrypted`, `encrypted:json`. See [Casts & Mutators](/docs/orm/casts) for the full cast reference.
202
+
203
+ A shorthand is not the same as `type`. `type` is only the storage type —
204
+ `string`, `text`, `number`, `boolean`, `datetime`, `json` — so `{ type: "integer" }`
205
+ and `{ type: "encrypted" }` are both errors. The shorthands that look like types
206
+ (`integer`, `float`, `encrypted`) are type-and-cast pairs: `@column("integer")` is
207
+ `{ type: "number", cast: "integer" }`, and `@column("encrypted")` is
208
+ `{ type: "text", cast: "encrypted" }`.
209
+
210
+ `string` is a bounded VARCHAR and `text` is the unbounded TEXT type — a distinction that matters on Postgres and MySQL, where a long body in a `VARCHAR(255)` is an error rather than a slow column.
211
+
212
+ Two casts surface as a type you might not expect, so declare the property to match
213
+ what you will actually hold: `date` hydrates a **native `Date`** (only `datetime`
214
+ gives you a [Carbon](/docs/carbon)), and `decimal:N` runs `.toFixed(N)` both ways,
215
+ so it is a **string**. TypeScript cannot catch either — the decorator does not
216
+ constrain the property type — so an annotation that disagrees compiles fine and
217
+ fails at the first `.diffForHumans()` or arithmetic.
218
+
219
+ ### Indexes and uniqueness
220
+
221
+ Declare constraints on the column and schema generation emits them, so `migrate:generate` produces a schema with the guarantees your application depends on rather than a bare set of columns:
222
+
223
+ ```typescript
224
+ @column({ unique: true }) idempotencyKey!: string; // unique index
225
+ @column({ index: true }) status!: string; // plain index
226
+ ```
227
+
228
+ Generated migrations also add an index to any column whose name ends in `_id` (`customerId` → `customer_id`), since an unindexed foreign key is a table scan on every join. The reference itself can't always be inferred; the index can.
229
+
230
+ ### Composing model mixins
231
+
232
+ Reusable model behaviour ships as **mixins** — soft deletes, state machines, the auth contract,
233
+ roles, permissions, notifications, tenancy, auditing. A model opts into the ones it wants with the
234
+ `Model.using(...)` static, so a model that does not use a feature does not carry its API:
235
+
236
+ ```typescript
237
+ import { Model, SoftDeletes } from "@zerotal/orm";
238
+ import { Authenticatable, Roles, Permissions } from "@zerotal/auth";
239
+
240
+ @table("users")
241
+ export class User extends Model.using(Authenticatable, Permissions, Roles) {
242
+ @column() email!: string;
243
+ }
244
+
245
+ @table("posts")
246
+ export class Post extends Model.using(SoftDeletes) {
247
+ @column() title!: string;
248
+ }
249
+ ```
250
+
251
+ Mixins fold left to right, and the composed class keeps the full Active Record static surface —
252
+ `User.query()`, `find()`, `create()`, scopes — plus every mixin's instance and static members,
253
+ fully typed. Prefer this over hand-nesting (`Roles(Permissions(AuthUser))`), which reads
254
+ inside-out and repeats the base.
255
+
256
+ The mixins the framework ships:
257
+
258
+ | Mixin | Package | Adds |
259
+ | ------------------------------------------- | ------------------------ | ---------------------------------------------------------- |
260
+ | [`SoftDeletes`](/docs/orm/lifecycle) | `@zerotal/orm` | `deletedAt`, `restore()`, `withTrashed()`, `onlyTrashed()` |
261
+ | [`State`](/docs/orm/lifecycle) | `@zerotal/orm` | `transitionTo()`, guarded state machines |
262
+ | [`Authenticatable`](/docs/authentication) | `@zerotal/auth` | the auth contract `Auth.attempt()` resolves against |
263
+ | [`Roles`](/docs/authorization) | `@zerotal/auth` | `assignRole()`, `hasRole()` |
264
+ | [`Permissions`](/docs/authorization) | `@zerotal/auth` | `givePermissionTo()`, `can()` |
265
+ | [`EmailVerification`](/docs/authentication) | `@zerotal/auth` | verification links and state |
266
+ | [`PasswordReset`](/docs/authentication) | `@zerotal/auth` | reset tokens |
267
+ | [`Notifiable`](/docs/notifications) | `@zerotal/notifications` | `notify()` and the notification channels |
268
+ | [`Tenantable`](/docs/tenancy) | `@zerotal/tenancy` | automatic per-tenant scoping |
269
+ | [`Auditable`](/docs/audit) | `@zerotal/audit` | change history |
270
+ | [`Media`](/docs/media) | `@zerotal/media` | file attachments, collections, image conversions |
271
+
272
+ #### Composing onto a shared base
273
+
274
+ `using` composes onto whatever class you call it on, not onto `Model` specifically, so an
275
+ app-level base model can carry its own configuration and still take mixins:
276
+
277
+ ```typescript
278
+ class AppModel extends Model {
279
+ static override primaryKey = "uuid";
280
+ }
281
+
282
+ @table("invoices")
283
+ export class Invoice extends AppModel.using(SoftDeletes) {} // still uses "uuid"
284
+ ```
285
+
286
+ The composed class carries `using` itself, so composition can also be chained —
287
+ `Model.using(A, B).using(C)`.
288
+
289
+ #### Writing your own
290
+
291
+ A mixin is a function taking a base constructor and returning a class that extends it:
292
+
293
+ ```typescript
294
+ import { Model, registerColumn, type Constructor } from "@zerotal/orm";
295
+
296
+ export function Sluggable<T extends Constructor>(Base: T) {
297
+ return class extends Base {
298
+ slug = "";
299
+
300
+ setSlug(from: string): this {
301
+ this.slug = from.toLowerCase().replace(/\s+/g, "-");
302
+ return this;
303
+ }
304
+ };
305
+ }
306
+
307
+ export class Article extends Model.using(Sluggable) {}
308
+ ```
309
+
310
+ > **Note** — a mixin that needs to declare a real database column must call `registerColumn()`
311
+ > imperatively. The `@column` decorator cannot run inside a returned class expression.
312
+
313
+ ## Mass assignment
314
+
315
+ Models **guard every attribute by default.** A model that declares neither
316
+ `fillable` nor `guarded` rejects _every_ attribute passed to `create()` / `fill()`,
317
+ throwing `MassAssignmentError`. This means a stray key in a request body — a
318
+ `role`, an `is_admin`, an `id` — can never reach the database just because it was
319
+ in `ctx.body()`. To allow columns through, you opt in explicitly.
320
+
321
+ ### fillable and guarded
322
+
323
+ `fillable` is an allowlist; `guarded` is a denylist. Use one or the other, not both.
324
+ Any attribute not permitted by the active list throws `MassAssignmentError` (it is
325
+ **not** silently dropped), so a mistake surfaces loudly instead of quietly failing:
326
+
327
+ ```typescript
328
+ // app/models/Post.ts
329
+ @table("posts")
330
+ export class Post extends Model {
331
+ // Only these fields are accepted by create() and fill()
332
+ static fillable = ["title", "body", "status"];
333
+
334
+ // OR — allow everything except these fields
335
+ static guarded = ["id", "userId"];
336
+
337
+ @column("string") title!: string;
338
+ @column("text") body!: string;
339
+ @column("string") status!: string;
340
+ }
341
+ ```
342
+
343
+ Use the `Columns<T>` utility for compile-time safety against typos:
344
+
345
+ ```typescript
346
+ // app/models/Post.ts
347
+ import type { Columns } from "@zerotal/orm";
348
+
349
+ static fillable: Columns<Post>[] = ["title", "body", "status"];
350
+ // TypeScript error if you list a column name that doesn't exist on Post
351
+ ```
352
+
353
+ Declare `fillable` as a literal tuple (`as const`) and `create()` narrows its payload to
354
+ exactly those columns:
355
+
356
+ ```typescript
357
+ @table("customers")
358
+ export class Customer extends Model {
359
+ static fillable = ["name", "email"] as const;
360
+
361
+ @column() name!: string;
362
+ @column() email!: string;
363
+ // A compliance flag that must never come from a request body
364
+ @column({ type: "boolean", cast: "boolean", default: false }) legalHold!: boolean;
365
+ }
366
+
367
+ await Customer.create({ name: "Ada", email: "ada@example.com" }); // ✓ legalHold not required
368
+ await Customer.create({ name: "Ada", email: "…", legalHold: true }); // ✗ compile error
369
+ ```
370
+
371
+ Without the `as const`, the payload is the full column set, so a required non-fillable
372
+ column is demanded by the type and rejected by the runtime — a pair of rules that cannot
373
+ both be satisfied. The narrowing makes the type agree with the guard, and moves the
374
+ mistake from a runtime `MassAssignmentError` to a compile error.
375
+
376
+ ### Trusted writes — forceFill, forceCreate, unguard
377
+
378
+ For data you construct yourself (seeders, factories, framework-internal writes)
379
+ the guard is just friction. Bypass it deliberately:
380
+
381
+ ```typescript
382
+ // Per call — skip the guard for one write:
383
+ role.forceFill({ name, guard });
384
+ await Role.forceCreate({ name, guard });
385
+
386
+ // A trusted block — guard disabled inside, restored afterwards (even on throw):
387
+ await Model.withoutGuard(() => seeder.run());
388
+
389
+ // Process-wide (e.g. a seeder entrypoint) — pair with reguard():
390
+ Model.unguard();
391
+ // … bulk trusted work …
392
+ Model.reguard();
393
+
394
+ // Or opt a single model out entirely (its writes never come from user input):
395
+ class AuditLog extends Model {
396
+ static override unguarded = true;
397
+ }
398
+ ```
399
+
400
+ An explicit `fillable` / `guarded` list is always honoured, even under a global
401
+ `unguard()` — so a model that lists its fillable columns stays protected regardless.
402
+
403
+ > **Tests:** test fixtures are trusted code that `create()` models freely, so the
404
+ > framework's test suites run with `Model.unguard()` enabled via a preload
405
+ > (`scripts/test-preload.ts`). If your app's tests build models directly rather
406
+ > than through factories, do the same, or declare `fillable` on the models.
407
+
408
+ ### fill instance method
409
+
410
+ ```typescript
411
+ // in a controller
412
+ post.fill({ title: "New title", body: "Updated body" }); // throws if a key isn't fillable
413
+ await post.save();
414
+
415
+ // Typical controller pattern — ctx.body() is already validated and typed:
416
+ post.fill(ctx.body<UpdatePayload<Post>>());
417
+ await post.save();
418
+ ```
419
+
420
+ ## Password hashing
421
+
422
+ Fields listed in `hashable` are automatically hashed with `Bun.password.hash()` (bcrypt) before every `INSERT`, and on `UPDATE` only when the value has changed since the last load:
423
+
424
+ ```typescript
425
+ // app/models/User.ts
426
+ @table("users")
427
+ export class User extends Model {
428
+ static hashable = ["password"];
429
+
430
+ @column("string") name!: string;
431
+ @column("string") email!: string;
432
+ @column("string") password!: string;
433
+ }
434
+
435
+ // No manual hashing needed — the ORM handles it transparently:
436
+ const user = await User.create({ name: "Alice", email: "alice@example.com", password: "secret" });
437
+
438
+ // Verify later:
439
+ const ok = await Bun.password.verify(candidate, user.password);
440
+ ```
441
+
442
+ ## Bridging model events to the app event bus
443
+
444
+ `dispatchesEvents` connects ORM lifecycle hooks to the application event bus without wiring every observer manually. Declare a map from lifecycle event names to event classes:
445
+
446
+ ```typescript
447
+ // app/events/UserCreated.ts
448
+ export class UserCreated {
449
+ constructor(public user: User) {}
450
+ }
451
+ ```
452
+
453
+ ```typescript
454
+ // app/models/User.ts
455
+ @table("users")
456
+ export class User extends Model {
457
+ static dispatchesEvents = {
458
+ created: UserCreated,
459
+ deleted: UserDeleted,
460
+ updating: UserUpdating,
461
+ };
462
+ }
463
+ ```
464
+
465
+ **Valid event keys:** `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`, `retrieved`.
466
+
467
+ Each event class is constructed with the model instance as its first argument and emitted on the container's event bus (a no-op when no bus is bound, so it's safe in standalone ORM use). Subscribe anywhere:
468
+
469
+ ```typescript
470
+ // app/listeners/sendWelcome.ts
471
+ import { Events } from "zerotal";
472
+ import { UserCreated } from "#app/events/UserCreated.ts";
473
+
474
+ Events.on(UserCreated, async ({ user }) => {
475
+ await Mail.send(new WelcomeMail(user.email));
476
+ });
477
+ ```
478
+
479
+ > **Tip** — Use `dispatchesEvents` when event consumers live in separate parts of your application
480
+ > and shouldn't be coupled to the model file. For logic that lives close to the model, reach for an
481
+ > observer instead — see [Lifecycle & Events](/docs/orm/lifecycle).
482
+
483
+ ## Generating models
484
+
485
+ ```bash
486
+ # in your project root
487
+ bun zt make:model Post
488
+ bun zt make:model Post --migration # also create a migration
489
+ bun zt make:model Post -m # shorthand
490
+ ```
491
+
492
+ A generated model skeleton (`app/models/Post.ts`):
493
+
494
+ ```typescript
495
+ // app/models/Post.ts
496
+ import { Model, column, table } from "@zerotal/orm";
497
+
498
+ @(table("posts").withTimestamps())
499
+ export class Post extends Model {
500
+ // Models guard every attribute by default — list the mass-assignable columns.
501
+ static fillable: string[] = ["name"];
502
+
503
+ @column() name!: string;
504
+ }
505
+ ```
506
+
507
+ ## Full model example
508
+
509
+ ```typescript
510
+ // app/models/Post.ts
511
+ import { Model, column, table, hasMany, belongsTo } from "@zerotal/orm";
512
+ import type { Columns } from "@zerotal/orm";
513
+ import { Carbon } from "zerotal/carbon";
514
+ import type { Comment } from "./Comment.ts";
515
+ import type { User } from "./User.ts";
516
+
517
+ @(table("posts").withTimestamps())
518
+ export class Post extends Model {
519
+ // Mass assignment
520
+ static fillable: Columns<Post>[] = ["title", "body", "status", "userId"];
521
+
522
+ // Serialization
523
+ static appends = ["excerpt"];
524
+
525
+ // Columns
526
+ @column("string") title!: string;
527
+ @column("text") body!: string;
528
+ @column("string") status!: string;
529
+ @column("integer") userId!: number;
530
+ @column("integer") views!: number;
531
+ @column("datetime") publishedAt?: Carbon;
532
+ @column("json") meta!: Record<string, unknown>;
533
+
534
+ // Relationships
535
+ @belongsTo(() => User, { foreignKey: "userId" })
536
+ author!: User;
537
+
538
+ @hasMany(() => Comment, { foreignKey: "postId" })
539
+ comments!: Comment[];
540
+
541
+ // Computed accessor
542
+ get excerpt(): string {
543
+ return this.body.slice(0, 160) + "…";
544
+ }
545
+ }
546
+ ```
547
+
548
+ ## Testing
549
+
550
+ Set your suite up once as described in [Testing](/docs/testing), and see
551
+ [Database Testing](/docs/testing/database) for rollback and the `assertDatabase*`
552
+ family. What follows is what's specific to models.
553
+
554
+ **Factories silence observers and hooks by default.** This is the single trap
555
+ worth knowing: `UserFactory.create()` writes the row without firing `creating`,
556
+ `created`, or any registered observer, so seeders don't spray logs, mail, and
557
+ jobs. A test asserting on a side-effect of creation therefore sees nothing —
558
+ and reads as a bug in your observer rather than in the test.
559
+
560
+ ```typescript
561
+ // tests/models/User.test.ts
562
+ import { test } from "bun:test";
563
+ import { QueueFake } from "@zerotal/queue";
564
+ import { UserFactory } from "../../database/factories/UserFactory.ts";
565
+ import { WelcomeEmailJob } from "../../app/jobs/WelcomeEmailJob.ts";
566
+
567
+ test("creating a user queues the welcome email", async () => {
568
+ const queue = QueueFake.install();
569
+
570
+ // Without dispatchEvents() the observer never runs and this assertion fails.
571
+ await UserFactory.dispatchEvents().create();
572
+
573
+ queue.assertDispatched(WelcomeEmailJob);
574
+ });
575
+ ```
576
+
577
+ **Scopes, casts, and accessors need no HTTP.** They are model behaviour, so test
578
+ them against the model directly — it is faster and the failure points at the
579
+ right line:
580
+
581
+ ```typescript
582
+ // tests/models/Post.test.ts
583
+ import { test, expect } from "bun:test";
584
+ import { PostFactory } from "../../database/factories/PostFactory.ts";
585
+ import { Post } from "../../app/models/Post.ts";
586
+
587
+ test("the published scope excludes drafts", async () => {
588
+ await PostFactory.create({ status: "published" });
589
+ await PostFactory.create({ status: "draft" });
590
+
591
+ const rows = await Post.query().published().get();
592
+
593
+ expect(rows).toHaveLength(1);
594
+ });
595
+ ```
596
+
597
+ **Soft deletes hide rows from the default query**, which makes "did it delete?"
598
+ ambiguous. Assert on both sides — gone from the normal query, present with
599
+ `withTrashed()`:
600
+
601
+ ```typescript
602
+ // tests/models/Post.test.ts
603
+ await post.delete();
604
+
605
+ expect(await Post.find(post.id)).toBeNull();
606
+ expect(await Post.query().withTrashed().where("id", post.id).first()).not.toBeNull();
607
+ ```
608
+
609
+ `assertDatabaseMissing("posts", { id })` would **fail** here, because the row is
610
+ still on disk with a `deleted_at` stamp. Use `onlyTrashed()` or assert on the
611
+ column instead.
612
+
613
+ ## References
614
+
615
+ Static configuration properties read from the model class:
616
+
617
+ | Property | Type | Description |
618
+ | ------------------ | ----------------------------------- | ------------------------------------------------------------------------------ |
619
+ | `table` | `string` | Table name. Set by `@table` or inferred from the class name. |
620
+ | `primaryKey` | `string` | Primary key column. Default `"id"`. |
621
+ | `timestamps` | `boolean` | Whether `created_at` / `updated_at` are managed. Default `true`. |
622
+ | `fillable` | `string[]` | Mass-assignment allowlist. Not permitted → `MassAssignmentError`. |
623
+ | `guarded` | `string[]` | Mass-assignment denylist (use instead of `fillable`). |
624
+ | `unguarded` | `boolean` | Disable mass-assignment guarding for this model. Default `false` (guarded). |
625
+ | `hashable` | `string[]` | Fields auto-hashed with `Bun.password.hash()` on change. |
626
+ | `hidden` | `string[]` | Fields omitted from JSON output. See [Serialization](/docs/orm/serialization). |
627
+ | `visible` | `string[]` | Allowlist of fields included in JSON output. |
628
+ | `appends` | `string[]` | Computed accessors appended to JSON output. |
629
+ | `dispatchesEvents` | `Record<string, new (m) => object>` | Maps lifecycle event keys to event classes emitted on the event bus. |
630
+ | `implicitBinding` | `boolean` | Set `false` to opt the model out of route-model binding. |
631
+
632
+ Type helpers exported from `@zerotal/orm`:
633
+
634
+ | Helper | Description |
635
+ | --------------------- | ------------------------------------------------------------------- |
636
+ | `Columns<T>` | Union of a model's column names, for typo-safe `fillable`/`hidden`. |
637
+ | `InsertPayload<T>` | The shape accepted by `create()`. |
638
+ | `UpdatePayload<T>` | The shape accepted by `fill()` / `update()`. |
639
+ | `DatabaseConfigShape` | The `config/database.ts` configuration type. |
640
+
641
+ ### Commands
642
+
643
+ `@zerotal/orm` ships the migration, model, and seeding commands. Every one runs through `bun zt`:
644
+
645
+ | Command | What it does |
646
+ | ------------------------------------------ | --------------------------------------------------------- |
647
+ | `bun zt migrate` | Run all pending database migrations (alias: `db:migrate`) |
648
+ | `bun zt migrate:rollback` | Roll back the most recent migration batch |
649
+ | `bun zt migrate:fresh` | Roll back every migration, then re-run them from scratch |
650
+ | `bun zt migrate:status` | Show the status of each migration |
651
+ | `bun zt migrate:generate` | Auto-generate a migration from model schema changes |
652
+ | `bun zt make:model Post --migration` | Create a model class, optionally with a migration |
653
+ | `bun zt make:migration create_posts_table` | Create a new migration file |
654
+ | `bun zt make:factory PostFactory` | Create a new model factory |
655
+ | `bun zt make:seeder PostSeeder` | Create a new database seeder class |
656
+ | `bun zt db:seed` | Run database seeders from `database/seeders/` |
657
+
658
+ ## Next steps
659
+
660
+ - [Casts & Mutators](/docs/orm/casts) — column types and custom casts.
661
+ - [Queries](/docs/orm/queries) — the query builder, scopes, and pagination.
662
+ - [Relationships](/docs/orm/relationships) — `belongsTo`, `hasMany`, eager loading.
663
+ - [Migrations](/docs/migrations) — build the tables your models map to.