@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- 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.
|
package/docs/database.md
ADDED
|
@@ -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.
|