@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
package/docs/csrf.md ADDED
@@ -0,0 +1,206 @@
1
+ ---
2
+ title: CSRF Protection
3
+ description: Block cross-site request forgery by requiring a per-session token on every mutating request.
4
+ ---
5
+
6
+ # CSRF Protection
7
+
8
+ Cross-site request forgery (CSRF) protection ensures that mutating requests
9
+ (POST/PUT/PATCH/DELETE) originate from your own app, not a malicious third-party
10
+ page. Zerotal's `CsrfMiddleware` (from `@zerotal/session`) handles this with a
11
+ per-session token and zero client config for Axios/Inertia.
12
+
13
+ ```typescript
14
+ // in a controller or middleware setup
15
+ import { CsrfMiddleware } from "@zerotal/session";
16
+ ```
17
+
18
+ > **Note** — `CsrfMiddleware` ships inside `@zerotal/session`; if the
19
+ > [session](/docs/session) package is already installed, there is nothing extra
20
+ > to add. Otherwise run `bun add @zerotal/session`.
21
+
22
+ ## Getting Started
23
+
24
+ CSRF protection ships inside `@zerotal/session` — installing the session
25
+ package is all it takes, and `SessionProvider` wires the middleware for you.
26
+
27
+ ```typescript
28
+ import { CsrfMiddleware } from "@zerotal/session";
29
+ ```
30
+
31
+ ## How it works
32
+
33
+ 1. On the first request, the middleware generates a random token
34
+ (`crypto.randomUUID()`) and stores it in the [session](/docs/session).
35
+ 2. On every response it sets a readable `XSRF-TOKEN` cookie (non-`HttpOnly`,
36
+ `SameSite=Lax`, `Path=/`).
37
+ 3. On a **mutating** request it requires that token back in an `X-CSRF-TOKEN` or
38
+ `X-XSRF-TOKEN` header, compared in constant time.
39
+ 4. A missing or wrong token returns **419** (`{ "message": "CSRF token mismatch." }`).
40
+
41
+ `GET`, `HEAD`, and `OPTIONS` are treated as safe and skip the check.
42
+
43
+ Because the cookie is readable by JavaScript, **Axios and Inertia attach the
44
+ `X-XSRF-TOKEN` header automatically** — first-party SPA forms work with no extra
45
+ wiring.
46
+
47
+ > **Danger** — The `XSRF-TOKEN` cookie intentionally omits the `HttpOnly` flag so
48
+ > JavaScript can read it. This is by design (it carries no auth, only the CSRF
49
+ > token), but never store a session secret or credential in a non-`HttpOnly`
50
+ > cookie.
51
+
52
+ ## Enabling it
53
+
54
+ Register `CsrfMiddleware` **after** `SessionMiddleware` — it reads and writes the
55
+ session. In production over HTTPS, set `secure: true` so the cookie carries the
56
+ `Secure` flag:
57
+
58
+ ```typescript
59
+ // bootstrap/app.ts
60
+ import { SessionMiddleware, CsrfMiddleware } from "@zerotal/session";
61
+
62
+ app.use([
63
+ SessionMiddleware,
64
+ CsrfMiddleware, // HTTP / development
65
+ // CsrfMiddleware.with({ secure: true }) // HTTPS / production
66
+ ]);
67
+ ```
68
+
69
+ `CsrfMiddleware.with({ ... })` returns a zero-argument middleware class with your
70
+ options baked in, so it drops straight into the `app.use([...])` array.
71
+
72
+ | Option | Required | Default | Description |
73
+ | -------- | -------- | ------- | ------------------------------------------------- |
74
+ | `secure` | no | `false` | Add the `Secure` flag to the `XSRF-TOKEN` cookie. |
75
+
76
+ > **Warning** — Register `CsrfMiddleware` after `SessionMiddleware`. Without an
77
+ > active session the token has nowhere to live and every mutating request fails
78
+ > with a 419.
79
+
80
+ ## Submitting the token
81
+
82
+ ### Axios / Inertia
83
+
84
+ No work needed — the `XSRF-TOKEN` cookie is read and sent back as `X-XSRF-TOKEN`
85
+ on every mutating request.
86
+
87
+ ### Manual fetch / classic forms
88
+
89
+ Expose the token server-side and send it in the header (or a hidden field your
90
+ handler reads). `CsrfMiddleware.token()` returns the current session's token:
91
+
92
+ ```typescript
93
+ // in an Inertia shared-props factory
94
+ import { CsrfMiddleware } from "@zerotal/session";
95
+
96
+ Inertia.share({ csrf_token: () => CsrfMiddleware.token() });
97
+
98
+ // …or embed it in an HTML meta tag for a classic page:
99
+ // <meta name="csrf-token" content="${CsrfMiddleware.token()}">
100
+ ```
101
+
102
+ ```typescript
103
+ // client-side fetch
104
+ await fetch("/posts", {
105
+ method: "POST",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content,
109
+ },
110
+ body: JSON.stringify(data),
111
+ });
112
+ ```
113
+
114
+ > **Tip** — `CsrfMiddleware.token()` defaults to the active request context, so it
115
+ > works inside a no-argument shared-prop factory. Pass an explicit `HttpContext`
116
+ > only when you call it outside the in-flight request.
117
+
118
+ ## Which approach should I use?
119
+
120
+ - **Axios or Inertia SPA** — do nothing. The cookie-to-header round trip is
121
+ automatic; the token is never your concern.
122
+ - **Hand-written `fetch` or a classic server-rendered form** — expose the token
123
+ with `CsrfMiddleware.token()` (meta tag or shared prop) and send it back in the
124
+ `X-CSRF-TOKEN` header or a hidden field.
125
+ - **Stateless bearer-token API** — skip CSRF entirely (see below).
126
+
127
+ ## Pure-API routes
128
+
129
+ CSRF protection guards **session-cookie** auth. Stateless APIs authenticated with
130
+ [bearer tokens](/docs/authentication#api-token-authentication) don't need it — a
131
+ token in the `Authorization` header can't be sent ambiently by a browser. Apply
132
+ `CsrfMiddleware` to your web/session routes and leave it off bearer-token API groups.
133
+
134
+ ## Testing
135
+
136
+ Set your suite up once as described in [Testing](/docs/testing). CSRF protection
137
+ is the rare feature whose test is mostly about proving a request **fails**.
138
+
139
+ **A rejected request is `419`, not `403`.** That status is specific enough to be
140
+ worth asserting by number — a `403` in this test means your authorization denied
141
+ the request and CSRF never ran:
142
+
143
+ ```typescript
144
+ // tests/http/csrf.test.ts
145
+ import { test } from "bun:test";
146
+ import { createApp } from "../helpers.ts";
147
+
148
+ test("an unsafe request without a token is refused", async () => {
149
+ const app = await createApp();
150
+
151
+ const res = await app.post("/posts", { title: "Hello" });
152
+
153
+ res.assertStatus(419);
154
+ await app.close();
155
+ });
156
+ ```
157
+
158
+ **Safe methods are exempt**, so a `GET` proves nothing about your CSRF setup. If
159
+ that is the only request in the test, the middleware could be absent entirely and
160
+ the suite would stay green:
161
+
162
+ ```typescript
163
+ // tests/http/csrf.test.ts
164
+ test("GET is never challenged", async () => {
165
+ (await app.get("/posts")).assertOk(); // passes with or without CSRF — not a CSRF test
166
+ });
167
+ ```
168
+
169
+ To test the **success** path, seed the session with a token you choose and send
170
+ the same value on the header the middleware reads:
171
+
172
+ ```typescript
173
+ // tests/http/csrf.test.ts
174
+ const token = "test-csrf-token";
175
+
176
+ const res = await app
177
+ .withSession({ _csrf_token: token })
178
+ .post("/posts", { title: "Hello" }, { "X-CSRF-Token": token });
179
+
180
+ res.assertCreated();
181
+ ```
182
+
183
+ The middleware accepts either `X-CSRF-Token` or `X-XSRF-Token`, and stores the
184
+ value under the `_csrf_token` session key — seeding it directly is both simpler
185
+ and less brittle than scraping the token out of a rendered form.
186
+
187
+ > **Note** — Exempting a route (a webhook receiver, say) is worth a test of its
188
+ > own asserting the request succeeds _without_ a token. That is the one case
189
+ > where a missing-token request passing is the correct outcome, and it should be
190
+ > deliberate rather than accidental.
191
+
192
+ ## References
193
+
194
+ | Member | Signature | Description |
195
+ | ---------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------- |
196
+ | `CsrfMiddleware` | `class CsrfMiddleware extends BaseMiddleware<CsrfOptions>` | Validates the token on mutating requests and sets the `XSRF-TOKEN` cookie. |
197
+ | `.with()` | `static with(options: Partial<CsrfOptions>): new () => CsrfMiddleware` | Bake `secure` into a zero-arg middleware class for `app.use([...])`. |
198
+ | `.token()` | `static token(ctx?: HttpContext): string \| undefined` | Read the current request's CSRF token; defaults to the active context. |
199
+ | `CsrfOptions` | `{ secure?: boolean }` | Options accepted by `.with()`. |
200
+
201
+ ## Next steps
202
+
203
+ - [Session](/docs/session) — where the CSRF token is stored.
204
+ - [Cookies](/docs/cookies) — the `XSRF-TOKEN` and session cookies.
205
+ - [Authentication](/docs/authentication) — session vs. bearer-token auth.
206
+ - [Middleware](/docs/middleware) — how `CsrfMiddleware` runs in the pipeline.
@@ -0,0 +1,476 @@
1
+ ---
2
+ title: Database
3
+ description: Talk to your database directly — transactions, raw queries, replicas, and locks — beneath the ORM.
4
+ ---
5
+
6
+ # Database
7
+
8
+ The `DB` facade is Zerotal's raw, model-free database layer — the escape hatch beneath the
9
+ [ORM](/docs/orm). Use it for transactions, fluent queries against arbitrary tables, raw SQL,
10
+ read/write replica routing, multiple connections, and advisory locks. Anything that touches
11
+ models lives in the [ORM docs](/docs/orm); anything that talks to the database directly lives
12
+ here.
13
+
14
+ Everything starts from a single import. Bring in the `DB` facade wherever you need
15
+ direct database access — a controller, a service, a seeder, or a job:
16
+
17
+ ```typescript
18
+ // in a controller, service, or seeder
19
+ import { DB } from "@zerotal/orm";
20
+ ```
21
+
22
+ The `DB` facade ships with `@zerotal/orm` and is wired up by the same `DatabaseProvider` —
23
+ there is no separate package to install or provider to register. See
24
+ [ORM](/docs/orm) for adding the package.
25
+
26
+ ## When to reach for it
27
+
28
+ Most day-to-day data access should go through a model — you get relations, casts, and
29
+ hooks for free. Drop down to the `DB` facade when a model is the wrong tool or simply
30
+ isn't there:
31
+
32
+ - **Transactions** that span several models or mix model and raw queries — `DB.transaction()`
33
+ is the one entry point that wraps everything inside it (see below).
34
+ - **Tables without a model** — pivot tables, reporting views, queue rows — where
35
+ hydrating a model buys you nothing.
36
+ - **Bulk writes** where you want to skip model events and lifecycle hooks for speed.
37
+ - **Raw SQL** for the rare query a fluent builder can't express — window functions,
38
+ CTEs, database-specific syntax.
39
+ - **Infrastructure concerns** — replica routing, multiple connections, advisory locks,
40
+ and N+1 detection all live here because they're cross-cutting, not model-specific.
41
+
42
+ Everything the `DB` facade returns is a plain row object, never a model instance. If you
43
+ need a hydrated model back, query through the model and pass the transaction along — see
44
+ [Transactions](#transactions) for how the active connection flows automatically.
45
+
46
+ ## Configuration
47
+
48
+ The database connection lives in `config/database.ts`. Use the `DatabaseConfig()` helper so
49
+ every field stays type-checked while literal values stay inferred:
50
+
51
+ ```typescript
52
+ // config/database.ts
53
+ import { DatabaseConfig } from "@zerotal/orm";
54
+ import { env } from "zerotal";
55
+
56
+ export default DatabaseConfig({
57
+ driver: "postgres",
58
+ url: env("DATABASE_URL", "./database/db.sqlite"),
59
+ replicas: [env("REPLICA_1_URL"), env("REPLICA_2_URL")],
60
+ });
61
+ ```
62
+
63
+ | Field | Required | Default | Description |
64
+ | ------------- | -------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- |
65
+ | `driver` | no | `"sqlite"` | Database driver: `"sqlite"`, `"postgres"`, or `"mysql"`. |
66
+ | `url` | no | `"./database/db.sqlite"` | Connection URL. For SQLite use a raw file path or `:memory:` (no protocol). |
67
+ | `replicas` | no | `[]` | Read-replica URLs. When set, reads round-robin across them (see below). |
68
+ | `pool` | no | `{ max: 10, idleTimeout: 30 }` | Pool tuning (`max`, `idleTimeout` in seconds) — PostgreSQL/MySQL only. |
69
+ | `sqlite` | no | `{ path: "./database/db.sqlite" }` | SQLite-specific options. `path` may be `:memory:`. |
70
+ | `synchronize` | no | `false` | Auto-sync schema to models at boot. Hard-off in production. See [Migrations](/docs/migrations). |
71
+
72
+ > **Note** — The container binds the active connection under the `db` key. `DatabaseProvider`
73
+ > creates it lazily on first use (`onRegister`), opens it and detects the dialect on boot
74
+ > (`onBooting`), and closes it on `onStopping` so nothing leaks between test suites.
75
+
76
+ > **Danger** — For SQLite do **not** prefix the URL with `sqlite://`. Bun's native SQLite
77
+ > driver expects a raw file path or `:memory:`; a protocol prefix is silently treated as a
78
+ > PostgreSQL connection string.
79
+
80
+ ## Transactions
81
+
82
+ A transaction makes a group of writes all-or-nothing: they commit together or, if
83
+ anything throws, none of them land. Reach for one whenever a single logical operation
84
+ touches more than one row or table and a half-finished state would be a bug — money
85
+ transfers, creating an order with its line items, anything with an invariant across
86
+ tables.
87
+
88
+ Three styles, in order of preference:
89
+
90
+ - **`DB.transaction(callback)`** — the default. The boundary is the callback; commit and
91
+ rollback are automatic. Use it for essentially everything.
92
+ - **Nested `DB.transaction()`** — when an inner step should be able to fail without
93
+ taking down the whole operation. Backed by SAVEPOINTs.
94
+ - **`DB.beginTransaction()`** — manual `commit()`/`rollback()` only when the boundary
95
+ can't fit in a callback (e.g. it spans an HTTP stream). You own the error handling.
96
+
97
+ ### Automatic commit/rollback
98
+
99
+ Pass a callback to `DB.transaction()`. Bun commits on resolve and rolls back on throw — you never call commit/rollback manually:
100
+
101
+ ```typescript
102
+ // in a controller or service
103
+ import { DB } from "@zerotal/orm";
104
+
105
+ await DB.transaction(async (trx) => {
106
+ const sender = await User.query(trx).where("id", senderId).lockForUpdate().firstOrFail();
107
+ const recipient = await User.query(trx).where("id", recipientId).lockForUpdate().firstOrFail();
108
+
109
+ if (sender.balance < amount) {
110
+ throw new Error("Insufficient balance");
111
+ }
112
+
113
+ await User.query(trx).where("id", senderId).decrement("balance", amount);
114
+ await User.query(trx).where("id", recipientId).increment("balance", amount);
115
+
116
+ await Transfer.query(trx).create({ senderId, recipientId, amount });
117
+ });
118
+ // Committed — or rolled back automatically if the block threw
119
+ ```
120
+
121
+ All `Model` and `DB` queries made inside the callback automatically use the transaction connection via `AsyncLocalStorage` — you don't need to pass `trx` explicitly unless you're mixing raw `DB.table()` calls with model calls:
122
+
123
+ ```typescript
124
+ // in a service
125
+ await DB.transaction(async () => {
126
+ // These automatically use the transaction without passing trx:
127
+ const user = await User.findOrFail(userId);
128
+ user.balance -= amount;
129
+ await user.save();
130
+
131
+ await AuditLog.create({ userId, action: "debit", amount });
132
+ });
133
+ ```
134
+
135
+ ### Nested transactions
136
+
137
+ Nested `DB.transaction()` calls automatically use SAVEPOINTs. An inner throw rolls back only the inner block, not the entire outer transaction:
138
+
139
+ ```typescript
140
+ // in a service
141
+ await DB.transaction(async () => {
142
+ await Order.create({ userId, total });
143
+
144
+ try {
145
+ await DB.transaction(async () => {
146
+ // Attempts to send a confirmation email
147
+ await EmailQueue.create({ to: user.email, template: "order-confirmation" });
148
+ // If this throws, only the inner block is rolled back
149
+ await externalEmailService.send(/* … */);
150
+ });
151
+ } catch {
152
+ // Outer transaction continues — order was still created
153
+ await Log.create({ message: "Email queuing failed", orderId: order.id });
154
+ }
155
+ });
156
+ ```
157
+
158
+ ### Retry on deadlock
159
+
160
+ Pass the number of attempts as a second argument to retry automatically on deadlock or serialization failures:
161
+
162
+ ```typescript
163
+ // in a service
164
+ await DB.transaction(async () => {
165
+ // critical concurrent write
166
+ }, 3); // retry up to 3 times on deadlock
167
+ ```
168
+
169
+ Zerotal detects `deadlock`, `serialization failure`, `could not serialize`, `sqlite_busy`,
170
+ `database is locked`, and SQLSTATE `40001` / `40P01` in error messages across all three drivers.
171
+
172
+ ### Manual transactions
173
+
174
+ For cases where the transaction boundary can't be expressed as a callback (e.g., spanning an HTTP response stream):
175
+
176
+ ```typescript
177
+ // in a service
178
+ import { DB } from "@zerotal/orm";
179
+
180
+ const t = await DB.beginTransaction();
181
+ try {
182
+ await t.table("accounts").where("id", 1).decrement("balance", 100);
183
+ await t.table("accounts").where("id", 2).increment("balance", 100);
184
+ await t.commit();
185
+ } catch (err) {
186
+ await t.rollback();
187
+ throw err;
188
+ }
189
+ ```
190
+
191
+ The `ManualTransaction` handle returned by `DB.beginTransaction()`:
192
+
193
+ | Member | Description |
194
+ | --------------- | ------------------------------------------------------ |
195
+ | `t.sql` | The raw transaction connection (tagged-template usage) |
196
+ | `t.table(name)` | Returns a `QueryBuilder` bound to this transaction |
197
+ | `t.commit()` | Commit and release the transaction |
198
+ | `t.rollback()` | Roll back and release the transaction |
199
+
200
+ > **Warning** — Prefer `DB.transaction(callback)`. Manual transactions require careful error handling to avoid leaving connections open.
201
+
202
+ ## Raw queries with DB
203
+
204
+ ### Fluent builder
205
+
206
+ `DB.table("name")` opens a query builder on any table — no model required. The same
207
+ chain handles all four CRUD operations: call a read terminal like `get()` to fetch
208
+ rows, or a write method like `insert()`, `update()`, or `delete()` to change them.
209
+ Values are always parameterised for you, so there's no injection risk:
210
+
211
+ ```typescript
212
+ // in a controller or service
213
+ import { DB } from "@zerotal/orm";
214
+
215
+ // SELECT — read rows, optionally typed with get<T>()
216
+ const rows = await DB.table("posts")
217
+ .where("user_id", userId)
218
+ .orderBy("created_at", "desc")
219
+ .get<{ id: number; title: string }>();
220
+
221
+ // INSERT — add a row
222
+ await DB.table("post_tags").insert({ post_id: 1, tag_id: 3 });
223
+
224
+ // UPDATE — scope with where() first, or you'll update every row
225
+ await DB.table("users").where("id", 1).update({ last_login: new Date().toISOString() });
226
+
227
+ // DELETE — same rule: always constrain with where()
228
+ await DB.table("sessions").where("user_id", userId).delete();
229
+ ```
230
+
231
+ The full builder surface — joins, grouping, aggregates, pagination, and more — is
232
+ documented in [Query Builder](/docs/query-builder).
233
+
234
+ ### DB.raw
235
+
236
+ Use when the query builder doesn't cover what you need.
237
+
238
+ > **Danger** — Always parameterise values; never interpolate them directly into the SQL string. String interpolation opens a SQL injection hole.
239
+
240
+ ```typescript
241
+ // in a service
242
+ import { DB } from "@zerotal/orm";
243
+
244
+ // String form with ? placeholders (safe)
245
+ const rows = await DB.raw<{ count: number }>(
246
+ "SELECT COUNT(*) as count FROM posts WHERE user_id = ?",
247
+ [userId],
248
+ );
249
+
250
+ // Tagged-template form (equally safe, more readable)
251
+ const tagged = await DB.raw<{ count: number }>`
252
+ SELECT COUNT(*) as count FROM posts WHERE user_id = ${userId}
253
+ `;
254
+
255
+ // No parameters
256
+ const [{ version }] = await DB.raw<{ version: string }>("SELECT version()");
257
+ ```
258
+
259
+ ### JSON column queries
260
+
261
+ `whereJson` takes the column and a JSON path joined with `->`, then the value to match:
262
+
263
+ ```typescript
264
+ // in a service
265
+ // Equivalent SQL: WHERE meta->>'notifications.email' = ?
266
+ await DB.table("settings").whereJson("meta->notifications.email", true).get();
267
+ ```
268
+
269
+ > **Note** — The column and path must match `/^[a-zA-Z_][a-zA-Z0-9_.]*$/`. An unsafe identifier
270
+ > throws rather than risk injection. A column with no `->` falls back to a plain `where`.
271
+
272
+ ## Read/write replicas
273
+
274
+ Configure replicas in `config/database.ts`:
275
+
276
+ ```typescript
277
+ // config/database.ts
278
+ import { DatabaseConfig } from "@zerotal/orm";
279
+ import { env } from "zerotal";
280
+
281
+ export default DatabaseConfig({
282
+ url: env("DATABASE_URL"),
283
+ replicas: [env("REPLICA_1_URL"), env("REPLICA_2_URL")],
284
+ });
285
+ ```
286
+
287
+ No model code changes needed. Routing is automatic:
288
+
289
+ | Query type | Connection |
290
+ | --------------------------------------------------------- | --------------------- |
291
+ | `SELECT`, `WITH`, `EXPLAIN`, `PRAGMA`, `SHOW`, `DESCRIBE` | Replica (round-robin) |
292
+ | `INSERT`, `UPDATE`, `DELETE`, DDL | Primary |
293
+ | `BEGIN` / `DB.transaction()` | Primary always |
294
+
295
+ ### Force primary for read-your-writes
296
+
297
+ After a write, the replica may lag. Use `DB.onPrimary()` when you need to read the just-written data immediately:
298
+
299
+ ```typescript
300
+ // in a controller
301
+ const post = await Post.create({ title: "Hello", userId });
302
+
303
+ // Read from primary to avoid replication lag:
304
+ const fresh = await DB.onPrimary().table("posts").where("id", post.id).first<Post>();
305
+ ```
306
+
307
+ ## N+1 detection
308
+
309
+ Automatically enabled outside production (the `onBooted` hook turns on `warn` mode with a
310
+ threshold of 5). When the same SQL shape fires more than 5 times in one request, Zerotal logs a
311
+ warning.
312
+
313
+ The detector reads the **bindings**, not just the SQL text, because the same SQL repeated is
314
+ two different bugs with two different fixes:
315
+
316
+ | What it saw | What it means | What it tells you |
317
+ | --------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
318
+ | Same SQL, **different** arguments | A per-row lookup — the classic N+1 | Eager-load the relation, or collapse it into `whereIn` |
319
+ | Same SQL, **same** arguments | The same answer fetched repeatedly | Ask once: [`RequestContext.remember`](/docs/context#asking-once-per-request) |
320
+
321
+ Without the bindings the two are indistinguishable, and a legitimate loop over six months —
322
+ identical SQL, a different `period` each time — got sent hunting for a relation to eager-load
323
+ that did not exist. `NPlusOneError.distinctArgs` carries the count if you want to branch on it.
324
+
325
+ ### Configuring the detector
326
+
327
+ Tune the detector once at boot. Lower the `threshold` to catch leaks sooner, and set
328
+ `mode: "throw"` in CI so an N+1 query fails the test suite instead of just logging:
329
+
330
+ ```typescript
331
+ // bootstrap/app.ts — or a service provider
332
+ DB.preventNPlusOne({
333
+ threshold: 3, // warn after 3 repetitions instead of 5
334
+ mode: "throw", // 'warn' (default) | 'throw' — throw in CI to fail tests
335
+ });
336
+ ```
337
+
338
+ ### Suppressing known patterns
339
+
340
+ Some repetition is intentional — a polling endpoint, an audit log — and you don't want
341
+ the detector crying wolf. Call `allowNPlusOne` to silence a specific table, either for
342
+ good or just for the current request:
343
+
344
+ ```typescript
345
+ // in a service provider or request handler
346
+ // Suppress permanently for a table/pattern
347
+ DB.allowNPlusOne("activity_logs");
348
+
349
+ // Suppress only for the current request
350
+ DB.allowNPlusOne("taggings", { once: true });
351
+ ```
352
+
353
+ The `pattern` argument is matched as a substring of the SQL query shape, so `"activity_logs"` matches any query against that table.
354
+
355
+ ## Multiple database connections
356
+
357
+ Register named connections in a service provider and opt models into them via `static connection`:
358
+
359
+ ```typescript
360
+ // in AppServiceProvider.onBooting()
361
+ import { Model } from "@zerotal/orm";
362
+
363
+ Model.registerConnection(
364
+ "analytics",
365
+ Bun.sql(Bun.env.ANALYTICS_DB_URL!),
366
+ "postgres", // dialect: 'sqlite' | 'postgres' | 'mysql'
367
+ );
368
+
369
+ Model.registerConnection("warehouse", Bun.sql(Bun.env.WAREHOUSE_DB_URL!), "postgres");
370
+ ```
371
+
372
+ With the connection registered, set `static connection` on any model that should live
373
+ there. From then on every query that model makes — reads, writes, pagination — is routed
374
+ to that connection with no extra arguments:
375
+
376
+ ```typescript
377
+ // app/models/AnalyticsEvent.ts
378
+ export class AnalyticsEvent extends Model {
379
+ static connection = "analytics";
380
+
381
+ @column("string") eventType!: string;
382
+ @column("datetime") occurredAt!: Carbon;
383
+ }
384
+
385
+ // All queries use the 'analytics' connection automatically:
386
+ const events = await AnalyticsEvent.query()
387
+ .where("event_type", "pageview")
388
+ .orderBy("occurred_at", "desc")
389
+ .paginate(50, 1);
390
+ ```
391
+
392
+ Connection resolution priority (highest to lowest):
393
+
394
+ 1. Active `DB.transaction()` / ALS transaction context
395
+ 2. `RequestContext._transaction` (request-scoped transaction)
396
+ 3. `static connection` named connection
397
+ 4. Default `db` connection (bound by `DatabaseProvider`)
398
+
399
+ ## PostgreSQL advisory locks
400
+
401
+ Use advisory locks for application-level mutual exclusion — e.g. preventing two workers from processing the same job simultaneously:
402
+
403
+ ```typescript
404
+ // in a job or worker
405
+ import { DB } from "@zerotal/orm";
406
+
407
+ // The callback runs with the lock held; the lock releases automatically on resolve or throw
408
+ await DB.advisoryLock(42, async () => {
409
+ const job = await Queue.query().where("status", "pending").first();
410
+ if (!job) return;
411
+
412
+ await job.update({ status: "processing" });
413
+ await processJob(job);
414
+ await job.update({ status: "done" });
415
+ });
416
+ ```
417
+
418
+ The key is an integer — use a consistent scheme (e.g. constants or hash of a resource ID) to avoid collisions across your codebase. Advisory locks are session-scoped in PostgreSQL and released automatically when the connection closes.
419
+
420
+ ## Events emitted
421
+
422
+ The database layer publishes framework events on the synchronous `FrameworkEvents`
423
+ instrumentation bus — subscribe to them for logging, metrics, or tracing. Register a handler
424
+ from a service provider:
425
+
426
+ | Event | Emitted when | Payload |
427
+ | ----------------------- | ------------------------------- | ------------------------------------------------------------- |
428
+ | `QueryExecuted` | After every SQL query completes | `sql`, `bindings`, `startMs`, `durationMs`, `rowCount`, `ctx` |
429
+ | `TransactionStarted` | A transaction begins | `txId`, `ctx` |
430
+ | `TransactionCommitted` | A transaction commits | `txId`, `durationMs`, `ctx` |
431
+ | `TransactionRolledBack` | A transaction rolls back | `txId`, `durationMs`, `reason`, `ctx` |
432
+ | `NPlusOneDetected` | The N+1 detector fires | `fingerprint`, `count`, `ctx` |
433
+ | `MigrationRan` | A migration runs up or down | `name`, `direction`, `durationMs`, `ok`, `error?` |
434
+
435
+ For example, subscribe to `QueryExecuted` in a provider to surface slow queries in your
436
+ logs — the handler receives the SQL, its bindings, and how long it took:
437
+
438
+ ```typescript
439
+ // in a service provider
440
+ import { FrameworkEvents, QueryExecuted } from "zerotal";
441
+
442
+ // Log slow queries
443
+ FrameworkEvents.on(QueryExecuted, (e) => {
444
+ if (e.durationMs > 100) {
445
+ logger.warn(`Slow query (${e.durationMs}ms): ${e.sql}`);
446
+ }
447
+ });
448
+ ```
449
+
450
+ This is the same `FrameworkEvents` bus used for HTTP, cache, and job instrumentation —
451
+ distinct from the application `Events` bus. See [Events](/docs/events) for the full catalogue,
452
+ the handler contract, and how to subscribe from a provider.
453
+
454
+ ## References
455
+
456
+ The `DB` facade surface:
457
+
458
+ | Method | Signature | Description |
459
+ | -------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
460
+ | `DB.table` | `table(name: string): QueryBuilder` | Start a fluent query against a table on the active connection. |
461
+ | `DB.raw` | `raw<T>(sql: TemplateStringsArray \| string, ...rest): Promise<T[]>` | Execute raw SQL via tagged template or `?`-placeholder string. |
462
+ | `DB.transaction` | `transaction<T>(cb: (tx?) => Promise<T>, attempts?: number): Promise<T>` | Run `cb` in a transaction; auto-commit/rollback, optional deadlock retry. |
463
+ | `DB.beginTransaction` | `beginTransaction(): Promise<ManualTransaction>` | Begin a transaction with manual `commit()`/`rollback()` control. |
464
+ | `DB.onPrimary` | `onPrimary(): { table(name): QueryBuilder }` | Query the primary connection, bypassing replicas (read-your-writes). |
465
+ | `DB.currentTx` | `currentTx(): unknown \| undefined` | The active transaction connection for this call site, if any. |
466
+ | `DB.advisoryLock` | `advisoryLock<T>(key: number, cb: () => Promise<T>): Promise<T>` | Hold a PostgreSQL advisory lock for the duration of `cb`. |
467
+ | `DB.preventNPlusOne` | `preventNPlusOne(options?: NPlusOneOptions): void` | Configure N+1 detection (`threshold`, `mode`). |
468
+ | `DB.allowNPlusOne` | `allowNPlusOne(pattern: string, options?: { once?: boolean }): void` | Suppress N+1 warnings for queries matching `pattern`. |
469
+ | `Model.registerConnection` | `registerConnection(name: string, conn: SQLInstance, dialect?): void` | Register a named connection that models opt into via `static connection`. |
470
+
471
+ ## Next steps
472
+
473
+ - [ORM](/docs/orm) — models, the model query builder, relationships, and everything model-centric.
474
+ - [Query Builder](/docs/query-builder) — the fluent builder behind `DB.table()`.
475
+ - [Migrations](/docs/migrations) — evolving the schema your models depend on.
476
+ - [Events](/docs/events) — the `FrameworkEvents` instrumentation bus.