@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,365 @@
1
+ ---
2
+ title: Casts & Mutators
3
+ description: Translate between raw database values and rich, typed TypeScript values on every read and write.
4
+ ---
5
+
6
+ # Casts & Mutators
7
+
8
+ Casts translate between the raw value stored in the database and the typed
9
+ TypeScript value you work with on your model. Declare them inline on `@column()`
10
+ or in a class-level `static casts` map; custom casts give you full control over
11
+ both the read (get) and write (set) transforms.
12
+
13
+ ## Basic usage
14
+
15
+ The shorthand string passed to `@column("…")` is a cast alias. Each one resolves
16
+ to a built-in get/set pair:
17
+
18
+ ```typescript
19
+ // app/models/Post.ts
20
+ import { Model, column, table } from "@zerotal/orm";
21
+ import { Carbon } from "zerotal/carbon";
22
+
23
+ @table("posts")
24
+ export class Post extends Model {
25
+ @column("string") title!: string;
26
+ @column("integer") views!: number;
27
+ @column("float") score!: number;
28
+ @column("boolean") published!: boolean;
29
+ @column("datetime") publishedAt!: Carbon;
30
+ @column("date") birthday?: Date;
31
+ @column("json") meta!: Record<string, unknown>;
32
+ @column("array") tags!: string[];
33
+ @column("text") bio?: string;
34
+ }
35
+ ```
36
+
37
+ ## Built-in cast shorthands
38
+
39
+ | Shorthand | TypeScript type | Read (DB → model) | Write (model → DB) |
40
+ | --------------------- | --------------- | ------------------------------ | ------------------ |
41
+ | `'string'` / `'text'` | `string` | as-is | as-is |
42
+ | `'integer'` | `number` | `parseInt()` | `parseInt()` |
43
+ | `'float'` | `number` | `parseFloat()` | `parseFloat()` |
44
+ | `'boolean'` | `boolean` | coerces `0`/`1`/`"1"`/`"true"` | writes `1` or `0` |
45
+ | `'datetime'` | `Carbon` | constructs a `Carbon` instance | ISO 8601 string |
46
+ | `'date'` | `Date` | constructs a native `Date` | ISO 8601 string |
47
+ | `'json'` | `unknown` | `JSON.parse()` | `JSON.stringify()` |
48
+ | `'array'` | `unknown[]` | `JSON.parse()` | `JSON.stringify()` |
49
+ | `'encrypted'` | `string` | decrypts under `APP_KEY` | AES-256-GCM |
50
+ | `'encrypted:json'` | `unknown` | decrypts, then `JSON.parse()` | stringify, encrypt |
51
+
52
+ > **Note** — `@column("date")` reads back a native `Date`, while
53
+ > `@column("datetime")` reads back a [Carbon](/docs/carbon) instance. Type the
54
+ > property accordingly.
55
+
56
+ ### Scalars in a json column
57
+
58
+ `json` and `array` encode on write and parse on read, in both directions, so a value
59
+ round-trips as the type you gave it — including a bare scalar:
60
+
61
+ ```typescript
62
+ setting.value = "62812345678"; // stored as "62812345678", read back as a string
63
+ setting.value = "051001"; // a branch code keeps its leading zero
64
+ setting.value = { plan: "pro" }; // objects and arrays as you would expect
65
+ ```
66
+
67
+ This is worth stating because the obvious alternative is wrong. Skipping the encode for
68
+ values that are already strings looks like it avoids double-encoding, but it makes the
69
+ column hold bare characters — and `JSON.parse("62812345678")` is a **number**. The value's
70
+ type would change between write and read, silently, for some values and not others.
71
+
72
+ If you are reading rows written by an older version that stored bare scalars, a value that
73
+ was a numeric string may come back as a number; coerce on read where it matters.
74
+
75
+ ## Advanced cast options
76
+
77
+ ### decimal:N — fixed-precision number
78
+
79
+ Reads and writes the value as a string with exactly `N` decimal places. Useful
80
+ for currency, where you want to avoid floating-point drift:
81
+
82
+ ```typescript
83
+ // app/models/Product.ts
84
+ @column({ type: "number", cast: "decimal:2" }) price!: string;
85
+ // DB stores "9.99" — the model reads it back as the string "9.99".
86
+ ```
87
+
88
+ > **Note** — Because both the read and write transforms call `.toFixed(N)`, a
89
+ > `decimal:N` column surfaces as a **string**, not a number. Type the property
90
+ > as `string`.
91
+
92
+ ### immutable_datetime — datetime alias
93
+
94
+ Behaves like `'datetime'` on read (constructs a [Carbon](/docs/carbon)) and
95
+ serializes to an ISO 8601 string on write:
96
+
97
+ ```typescript
98
+ // app/models/Booking.ts
99
+ @column({ type: "datetime", cast: "immutable_datetime" }) lockedAt?: Carbon;
100
+
101
+ const tomorrow = booking.lockedAt?.add(1, "day"); // returns a new Carbon
102
+ ```
103
+
104
+ > **Note** — Every `Carbon` is already immutable: each modifier such as `add()`
105
+ > returns a _new_ instance and never mutates the original. So
106
+ > `immutable_datetime` and `datetime` produce equivalent values — always assign
107
+ > the result of a modifier rather than relying on in-place mutation.
108
+
109
+ ### enum — TypeScript enums
110
+
111
+ Stores and retrieves the raw enum value (the underlying string or number);
112
+ TypeScript narrows the property type. The cast itself is a pass-through, so pair
113
+ it with `enumValues` to document the enum:
114
+
115
+ ```typescript
116
+ // app/models/Post.ts
117
+ enum Status {
118
+ Draft = "draft",
119
+ Published = "published",
120
+ Archived = "archived",
121
+ }
122
+
123
+ @column({ type: "string", cast: "enum", enumValues: Status }) status!: Status;
124
+
125
+ // TypeScript now knows post.status is Status, not string:
126
+ if (post.status === Status.Published) { /* … */ }
127
+ ```
128
+
129
+ ### encrypted — ciphertext at rest
130
+
131
+ The column stores an opaque AES-256-GCM payload keyed by `APP_KEY`; the property
132
+ holds the value you assigned. Nothing in between — your code, validation,
133
+ `$dirty` — has to know:
134
+
135
+ ```typescript
136
+ // app/models/Client.ts
137
+ @column("encrypted", { nullable: true }) idNumber?: string;
138
+
139
+ // Structured values need the :json variant, so the type round-trips:
140
+ @column("encrypted:json", { nullable: true }) medical?: MedicalInfo;
141
+
142
+ // The same thing spelled out. `encrypted` is a cast, not a storage type —
143
+ // `{ type: "encrypted" }` is not a thing:
144
+ @column({ type: "text", nullable: true, cast: "encrypted" }) passportNumber?: string;
145
+ ```
146
+
147
+ The shorthand resolves to `{ type: "text", cast: "encrypted" }`, which is why it is
148
+ worth preferring: it gets the storage type right without you having to remember
149
+ that ciphertext outgrows its plaintext.
150
+
151
+ For several columns at once, list them instead — it means exactly the same thing:
152
+
153
+ ```typescript
154
+ class Client extends BaseModel {
155
+ static encryptable = ["idNumber", "passportNumber"];
156
+ }
157
+ ```
158
+
159
+ A column in that list whose `@column({ type })` is `json` encrypts as
160
+ `encrypted:json` automatically, so the structure survives the round trip rather
161
+ than reaching the cipher as `"[object Object]"`.
162
+
163
+ Unlike [`hashable`](/docs/orm/index), this is reversible and does not touch the
164
+ instance: after `save()`, `client.idNumber` still reads as the plaintext you set.
165
+ `$dirty` therefore compares plaintext, and an unchanged column is not rewritten
166
+ with a fresh IV on every unrelated save.
167
+
168
+ > **Danger** — **You cannot query an encrypted column.** Every write draws a new
169
+ > IV, so the same value encrypts to different ciphertext each time and an equality
170
+ > match can never hit. `where()` on one throws `EncryptedColumnError` rather than
171
+ > quietly returning zero rows. If you need lookup, keep a separate hashed column
172
+ > (a blind index) beside it and query that. Sorting and grouping are meaningless
173
+ > for the same reason, and are not guarded.
174
+
175
+ Two more things worth knowing:
176
+
177
+ - **Declare the column as `text`.** A payload is roughly 1.4× the plaintext plus
178
+ 28 bytes, so a `VARCHAR(255)` that held the value will not hold its ciphertext.
179
+ `migrate:generate` and `synchronize()` widen an encrypted column to TEXT for you
180
+ — the generated migration says `table.text(...)` — because MySQL outside strict
181
+ mode truncates instead of failing, and a truncated payload never decrypts.
182
+ - **Add them to `hidden`** if the model is serialized to a client. Decryption puts
183
+ the real value back on the instance, and `toJSON()` will include it.
184
+
185
+ **Turning encryption on for a column that already holds data** needs a back-fill
186
+ first: existing plaintext rows are not decryptable, and reading one throws
187
+ `EncryptedColumnError` naming the model and column. Read the rows with the cast
188
+ off, then write them back with it on. The same error covers a rotated `APP_KEY` —
189
+ decrypt with the old key and re-save. Failing the read is deliberate: handing back
190
+ the ciphertext would put an unreadable value where the application expects a real
191
+ one, and re-encrypt it on the next save, losing the original for good.
192
+
193
+ ### Custom cast — full get/set control
194
+
195
+ Pass an object with `get` and `set` functions for complete control over
196
+ serialization:
197
+
198
+ ```typescript
199
+ // app/models/Place.ts
200
+ interface GeoPoint { lat: number; lng: number }
201
+
202
+ @column({
203
+ type: "string",
204
+ cast: {
205
+ get: (v: unknown): GeoPoint => JSON.parse(v as string),
206
+ set: (v: unknown): string => JSON.stringify(v),
207
+ },
208
+ })
209
+ location!: GeoPoint;
210
+
211
+ // You now work with a typed object, not a raw string:
212
+ console.log(place.location.lat, place.location.lng);
213
+ ```
214
+
215
+ ## Reusable casts
216
+
217
+ For a cast you reuse across models, extend the `Cast` base class instead of
218
+ repeating an inline `{ get, set }` object. Put your cast in `app/casts/` and
219
+ pass an instance:
220
+
221
+ ```typescript
222
+ // app/casts/MoneyCast.ts
223
+ import { Cast } from "@zerotal/orm";
224
+
225
+ export class MoneyCast extends Cast<number> {
226
+ get(db: unknown) {
227
+ return Number(db) / 100;
228
+ } // cents → dollars
229
+ set(v: number) {
230
+ return Math.round(v * 100);
231
+ }
232
+ }
233
+ ```
234
+
235
+ ```typescript
236
+ // app/models/Invoice.ts
237
+ import { column } from "@zerotal/orm";
238
+ import { MoneyCast } from "../casts/MoneyCast.ts";
239
+
240
+ @column({ cast: new MoneyCast() }) total!: number;
241
+ ```
242
+
243
+ For JSON columns the ORM ships ready-made helpers that optionally hydrate the
244
+ parsed value into a class:
245
+
246
+ ```typescript
247
+ // app/models/Customer.ts
248
+ import { column } from "@zerotal/orm";
249
+ import { json, objectOf, arrayOf } from "@zerotal/orm";
250
+ import { Address } from "../value-objects/Address.ts";
251
+
252
+ @column({ cast: json<Settings>() }) settings!: Settings; // typed plain JSON
253
+ @column({ cast: objectOf(Address) }) billing!: Address; // hydrate one object
254
+ @column({ cast: arrayOf(Address) }) addresses!: Address[]; // hydrate a list
255
+ ```
256
+
257
+ > **Tip** — A class passed to `objectOf`/`arrayOf` is hydrated without invoking
258
+ > its constructor (via `Object.assign` on the prototype). Define a static
259
+ > `fromJSON(raw)` on the class to customise how a row is rebuilt.
260
+
261
+ ## static casts map
262
+
263
+ An alternative to `@column()` for columns you don't declare directly (e.g. from
264
+ an external schema, a view, or a generated table):
265
+
266
+ ```typescript
267
+ // app/models/Post.ts
268
+ @table("posts")
269
+ export class Post extends Model {
270
+ static casts = {
271
+ publishedAt: "datetime",
272
+ meta: "json",
273
+ price: "decimal:2",
274
+ active: "boolean",
275
+ } as const;
276
+ }
277
+ ```
278
+
279
+ `static casts` and `@column()` can coexist. Casts are merged up the prototype
280
+ chain, so a subclass inherits its parent's casts without re-declaring them.
281
+
282
+ > **Warning** — When a column is declared in **both** `static casts` and
283
+ > `@column()`, the `static casts` entry wins — it is checked first during
284
+ > hydration. Pick one place to define a column's cast to avoid surprises.
285
+
286
+ ### Which should I use?
287
+
288
+ - **`@column("…")` shorthand** — the default. Co-locates the cast with the
289
+ property and gives you the TypeScript type in one place.
290
+ - **`@column({ cast })` object / `Cast` class** — when you need a custom
291
+ transform, a `decimal:N`/`enum` option, or a reusable cast shared by several
292
+ models.
293
+ - **`static casts` map** — when the property isn't declared with `@column()`
294
+ (external/generated schemas) or you want all casts listed in one table.
295
+
296
+ ## Reactive JSON casts
297
+
298
+ By default, mutating a nested JSON property directly (e.g. `post.meta.views++`)
299
+ does not mark the column dirty and won't be persisted on the next `save()`.
300
+ Enable `reactiveCasts` to make `json` and `array` columns use a reactive proxy
301
+ that tracks deep mutations:
302
+
303
+ ```typescript
304
+ // app/models/Post.ts
305
+ @table("posts")
306
+ export class Post extends Model {
307
+ static reactiveCasts = true;
308
+
309
+ @column("json") meta!: Record<string, unknown>;
310
+ }
311
+
312
+ const post = await Post.find(1);
313
+
314
+ // With reactiveCasts = true, this nested mutation IS tracked:
315
+ post.meta.views = (post.meta.views as number) + 1;
316
+ await post.save(); // persists the updated meta
317
+ ```
318
+
319
+ Without `reactiveCasts`, replace the whole value to ensure dirty tracking:
320
+
321
+ ```typescript
322
+ // in a controller
323
+ post.meta = { ...post.meta, views: (post.meta.views as number) + 1 };
324
+ await post.save();
325
+ ```
326
+
327
+ > **Note** — Enable `reactiveCasts` per model. There is no performance cost on
328
+ > models that don't use it.
329
+
330
+ ## Cast application order
331
+
332
+ Casts are applied:
333
+
334
+ - **On read** — immediately after the row is hydrated from the database.
335
+ - **On write** — just before the value is sent to the database in `save()`,
336
+ `create()`, or `update()`.
337
+ - **In dirty tracking** — the hydrated (post-read-cast) value is captured as the
338
+ original, so `isDirty()` reflects actual changes, not cast-representation
339
+ differences.
340
+
341
+ ## References
342
+
343
+ Custom-cast surface, all exported from `@zerotal/orm`:
344
+
345
+ | Member | Signature | Description |
346
+ | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------- |
347
+ | `Cast<T>` | `abstract class Cast<T> { get(db); set(v) }` | Base class for a reusable custom cast. |
348
+ | `CastContract<T>` | `interface { get(db): T; set(v: T): unknown }` | The shape any `{ get, set }` cast must satisfy. |
349
+ | `json<T>(mapper?)` | `(mapper?: CastMapper<T>) => JsonCast<T>` | Cast a JSON column to a typed object, optionally hydrated. |
350
+ | `objectOf<T>(mapper?)` | `(mapper?: CastMapper<T>) => JsonCast<T>` | Alias of `json`, reads nicely with a class. |
351
+ | `arrayOf<T>(mapper?)` | `(mapper?: CastMapper<T>) => ArrayCast<T>` | Cast a JSON column to an array of typed values. |
352
+
353
+ Cast options accepted by `@column()`:
354
+
355
+ | Option | Type | Description |
356
+ | ------------ | --------------------------------------------------- | ----------------------------------------------- |
357
+ | `cast` | shorthand string, `{ get, set }`, or `CastContract` | The transform applied on read/write. |
358
+ | `enumValues` | `Record<string, string \| number>` | The TS enum object, paired with `cast: "enum"`. |
359
+
360
+ ## Next steps
361
+
362
+ - [ORM](/docs/orm/index) — defining models and columns.
363
+ - [Queries](/docs/orm/queries) — the query builder and scopes.
364
+ - [Serialization](/docs/orm/serialization) — control JSON output.
365
+ - [Carbon](/docs/carbon) — the date type behind `datetime` casts.
@@ -0,0 +1,231 @@
1
+ ---
2
+ title: Factories
3
+ description: Generate realistic model instances on demand for seeders and tests.
4
+ ---
5
+
6
+ # Factories
7
+
8
+ Factories produce model instances with realistic fake data. Define the default
9
+ shape once, then spin up one record or a thousand — in [seeders](/docs/seeding) or
10
+ [tests](/docs/testing).
11
+
12
+ ## Getting Started
13
+
14
+ Factories ship in `@zerotal/testing`. There is no provider or config file —
15
+ import the `Factory` class and start defining:
16
+
17
+ ```bash
18
+ # in your project root
19
+ bun add @zerotal/testing
20
+ ```
21
+
22
+ ```typescript
23
+ // database/factories/PostFactory.ts
24
+ import { Factory } from "@zerotal/testing";
25
+ ```
26
+
27
+ ## Defining a factory
28
+
29
+ Generate one with the CLI:
30
+
31
+ ```bash
32
+ # in your project root
33
+ bun zt make:factory Post
34
+ ```
35
+
36
+ This writes `database/factories/PostFactory.ts`. A factory is `Factory.define(Model,
37
+ definition)`, where the definition callback receives the built-in [`fake`](#the-fake-helper)
38
+ helper and returns the model's default attributes:
39
+
40
+ ```typescript
41
+ // database/factories/PostFactory.ts
42
+ import { Factory } from "@zerotal/testing";
43
+ import { Post } from "../../app/models/Post.ts";
44
+
45
+ export const PostFactory = Factory.define(Post, (fake) => ({
46
+ title: fake.sentence({ words: 5 }),
47
+ body: fake.paragraph(),
48
+ slug: fake.string(12),
49
+ publishedAt: fake.pastDate(),
50
+ }));
51
+ ```
52
+
53
+ The definition is **type-safe** against the model's insert payload. Foreign-key
54
+ fields (anything ending in `Id`, e.g. `userId`) are optional in the definition —
55
+ supply them at create time with [`.for()`](#relating-models) or an override.
56
+
57
+ ## Creating records
58
+
59
+ ```typescript
60
+ // in a test or seeder
61
+ // Persist one record → Promise<Post>
62
+ const post = await PostFactory.create();
63
+
64
+ // Override any attribute
65
+ const draft = await PostFactory.create({ publishedAt: null });
66
+
67
+ // Persist many → Promise<Post[]>
68
+ const posts = await PostFactory.count(20).create();
69
+ const five = await PostFactory.count(5).create({ status: "published" });
70
+
71
+ // Insert n without batch mode (also returns Post[])
72
+ const three = await PostFactory.createMany(3);
73
+
74
+ // Build in memory WITHOUT touching the database → Post
75
+ const unsaved = PostFactory.make({ title: "Preview" });
76
+ ```
77
+
78
+ | Method | Returns | Touches DB |
79
+ | ----------------------------- | -------------- | ---------- |
80
+ | `create(overrides?)` | `Promise<T>` | Yes |
81
+ | `count(n).create(overrides?)` | `Promise<T[]>` | Yes |
82
+ | `createMany(n, overrides?)` | `Promise<T[]>` | Yes |
83
+ | `make(overrides?)` | `T` | No |
84
+
85
+ Override precedence is: definition defaults → relation FKs → your overrides (last
86
+ wins).
87
+
88
+ > **Tip** — Both `count(n).create()` and `createMany(n)` insert sequentially (not in
89
+ > parallel) so they stay correct on SQLite's single-write `last_insert_rowid()`.
90
+
91
+ ## Relating models
92
+
93
+ `.for(parent)` injects the parent's primary key as a foreign key, derived from the
94
+ parent's class name (`User` → `userId`):
95
+
96
+ ```typescript
97
+ // in a test or seeder
98
+ const user = await UserFactory.create();
99
+
100
+ const post = await PostFactory.for(user).create(); // sets post.userId
101
+ const authored = await PostFactory.for(user, "authorId").create(); // custom FK column
102
+
103
+ // Chain multiple parents
104
+ const comment = await CommentFactory.for(post).for(user).create();
105
+ ```
106
+
107
+ ## Modifiers
108
+
109
+ All modifiers return a new factory (they don't mutate), so they compose freely and a
110
+ base factory stays reusable.
111
+
112
+ ### state
113
+
114
+ Force the created instance into a model state via `forceState()` (bypassing guards
115
+ and transition callbacks — see [Lifecycle & Events](/docs/orm/lifecycle)):
116
+
117
+ ```typescript
118
+ // in a test or seeder
119
+ const expired = await SubscriptionFactory.state("expired").create();
120
+ ```
121
+
122
+ ### afterCreate
123
+
124
+ Run logic after each instance is saved — e.g. attaching related records:
125
+
126
+ ```typescript
127
+ // in a test or seeder
128
+ const user = await UserFactory.afterCreate(async (u) => {
129
+ await PostFactory.for(u).count(3).create();
130
+ }).create();
131
+ ```
132
+
133
+ ### dispatchEvents
134
+
135
+ Factories suppress model observers and hooks by default. Opt back in when a test
136
+ needs the full lifecycle to fire:
137
+
138
+ > **Note** — Suppressing hooks keeps seeding side-effect free (no logs, emails, or
139
+ > queued jobs). Call `dispatchEvents()` to let observers and hooks run.
140
+
141
+ ```typescript
142
+ // in a test
143
+ // Silent — no "user registered" side effects:
144
+ await UserFactory.count(20).create();
145
+
146
+ // Fire observers/hooks so you can assert a side effect:
147
+ const user = await UserFactory.dispatchEvents().create();
148
+ Queue.assertDispatched(WelcomeEmailJob);
149
+ ```
150
+
151
+ ## In seeders
152
+
153
+ ```typescript
154
+ // database/seeders/DatabaseSeeder.ts
155
+ import { Seeder } from "@zerotal/orm";
156
+ import { UserFactory } from "../factories/UserFactory.ts";
157
+ import { PostFactory } from "../factories/PostFactory.ts";
158
+
159
+ export class DatabaseSeeder extends Seeder {
160
+ async run(): Promise<void> {
161
+ const authors = await UserFactory.count(10).create();
162
+ for (const author of authors) {
163
+ await PostFactory.for(author).count(5).create();
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ See [Seeding](/docs/seeding) for running seeders.
170
+
171
+ ## In tests
172
+
173
+ Factories are the standard way to arrange database state in a test:
174
+
175
+ ```typescript
176
+ // in a test
177
+ import { UserFactory } from "../../database/factories/UserFactory.ts";
178
+
179
+ test("an editor can publish", async () => {
180
+ const editor = await UserFactory.state("editor").create();
181
+ const post = await PostFactory.for(editor).create({ status: "draft" });
182
+ // … act and assert
183
+ });
184
+ ```
185
+
186
+ See [Database Testing](/docs/testing/database) for refreshing state between tests.
187
+
188
+ ## The fake helper
189
+
190
+ The definition callback's argument is the built-in `fake` generator (also importable
191
+ as `import { fake } from "@zerotal/testing"`). A selection:
192
+
193
+ | Category | Methods |
194
+ | ---------- | ------------------------------------------------------------------------------------------------------------- |
195
+ | Primitives | `number(min?, max?)`, `float(min?, max?, decimals?)`, `boolean(trueWeight?)`, `uuid()`, `string(length?)` |
196
+ | Picking | `pick(arr)`, `sample(arr, n)`, `shuffle(arr)`, `maybe(value, probability?)` |
197
+ | Dates | `date(from?, to?)`, `pastDate(years?)`, `futureDate(years?)`, `isoDate(from?, to?)`, `timestamp()` |
198
+ | People | `firstName()`, `lastName()`, `name()`, `email(opts?)`, `phone()` |
199
+ | Places | `city()`, `province()`, `suburb()`, `streetAddress()`, `postalCode()`, `address()` |
200
+ | Company | `company()`, `jobTitle()`, `department()` |
201
+ | Text | `word()`, `words(n?)`, `sentence(opts?)`, `sentences(n?, opts?)`, `paragraph(opts?)`, `paragraphs(n?, opts?)` |
202
+ | Web | `title()`, `slug(text?)`, `url(opts?)`, `password(opts?)` |
203
+
204
+ You're free to ignore `fake` and use any data source you like inside the definition.
205
+
206
+ ## References
207
+
208
+ The full factory surface. Every modifier returns a new factory, so chains compose
209
+ without mutating the base.
210
+
211
+ | Method | Signature | Description |
212
+ | ---------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- |
213
+ | `define` | `Factory.define(Model, (fake) => FactoryPayload<T>): Factory<T>` | Create a reusable factory for a model class. |
214
+ | `create` | `create(overrides?: Partial<InsertPayload<T>>): Promise<T>` | Insert one record and return it. |
215
+ | `createMany` | `createMany(n: number, overrides?): Promise<T[]>` | Insert `n` records sequentially without batch mode. |
216
+ | `make` | `make(overrides?: Partial<InsertPayload<T>>): T` | Build one in-memory instance; does not touch the database. |
217
+ | `count` | `count(n: number): FactoryBatch<T>` | Switch to batch mode; the returned `create()` yields `Promise<T[]>`. |
218
+ | `for` | `for(model: Model, foreignKey?: string): Factory<T>` | Inject a parent's id as a foreign key (default `<model>Id`). |
219
+ | `state` | `state(stateName: string): Factory<T>` | Force created instances into a model state via `forceState()`. |
220
+ | `afterCreate` | `afterCreate(cb: (instance: T) => Promise<void> \| void): Factory<T>` | Run `cb` after each instance is saved. |
221
+ | `dispatchEvents` | `dispatchEvents(): Factory<T>` | Let observers and hooks fire (suppressed by default). |
222
+
223
+ > **Note** — `FactoryBatch` (returned by `count(n)`) mirrors `for`, `state`,
224
+ > `afterCreate`, and `dispatchEvents`, but its `create()` returns `Promise<T[]>`.
225
+
226
+ ## Next steps
227
+
228
+ - [Seeding](/docs/seeding) — populating the database for demos and tests.
229
+ - [Database Testing](/docs/testing/database) — resetting state between tests.
230
+ - [Lifecycle & Events](/docs/orm/lifecycle) — model states that `state()` targets.
231
+ - [ORM](/docs/orm) — the models a factory builds.