@zerotal/arch 1.7.4 → 1.7.5
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 +34 -2
- package/README.md +4 -2
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +105 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +4 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +28 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +2 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +5 -5
- package/docs/flow/index.md +20 -20
- package/docs/flow/layouts.md +35 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +6 -6
- package/docs/inertia/props.md +21 -21
- package/docs/inertia/rendering.md +9 -9
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +23 -10
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +3 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/index.ts +25 -1
- package/src/tools/searchDocs.ts +7 -0
package/docs/pagination.md
CHANGED
|
@@ -25,13 +25,13 @@ carrying the rows plus the metadata, cursors, and URL helpers a UI needs.
|
|
|
25
25
|
Pagination ships with `@zerotal/orm` as query-builder methods — nothing to
|
|
26
26
|
install or register. Call them at the end of any query:
|
|
27
27
|
|
|
28
|
-
```typescript
|
|
28
|
+
```typescript fragment
|
|
29
29
|
const page = await Post.query().latest().paginate();
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## paginate — full numbered pages
|
|
33
33
|
|
|
34
|
-
```typescript
|
|
34
|
+
```typescript fragment
|
|
35
35
|
// in a controller
|
|
36
36
|
const page = await Post.query()
|
|
37
37
|
.where("status", "published")
|
|
@@ -48,7 +48,7 @@ complete picture — at the cost of the extra count query. `perPage` defaults to
|
|
|
48
48
|
omit `page` and it reads the request's current page (`?page=`, or the page a Flow
|
|
49
49
|
component registered).
|
|
50
50
|
|
|
51
|
-
```typescript
|
|
51
|
+
```typescript fragment
|
|
52
52
|
async paginate<T>(perPage?: number, page?: number): Promise<PaginateResult<T>>
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -77,7 +77,7 @@ interface PaginateResult<T> {
|
|
|
77
77
|
|
|
78
78
|
### URL & state helpers
|
|
79
79
|
|
|
80
|
-
```typescript
|
|
80
|
+
```typescript fragment
|
|
81
81
|
// using a PaginateResult `page`
|
|
82
82
|
page.hasMorePages; // boolean
|
|
83
83
|
page.onFirstPage; // boolean
|
|
@@ -101,7 +101,7 @@ and extra query params, which are preserved across links so filters survive
|
|
|
101
101
|
Skips the `COUNT` entirely by fetching `perPage + 1` rows to detect whether
|
|
102
102
|
another page follows. Use it when you don't need a total or page numbers:
|
|
103
103
|
|
|
104
|
-
```typescript
|
|
104
|
+
```typescript fragment
|
|
105
105
|
// in a controller
|
|
106
106
|
const page = await Post.query().latest().simplePaginate(20);
|
|
107
107
|
|
|
@@ -111,7 +111,7 @@ page.nextPageUrl();
|
|
|
111
111
|
page.onFirstPage;
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
```typescript
|
|
114
|
+
```typescript fragment
|
|
115
115
|
async simplePaginate<T>(perPage?: number, page?: number): Promise<SimplePaginateResult<T>>
|
|
116
116
|
```
|
|
117
117
|
|
|
@@ -128,7 +128,7 @@ BY id ASC`, fetching `limit + 1` rows to detect a next page. It has **no
|
|
|
128
128
|
`COUNT`** and stays fast no matter how deep you scroll — ideal for
|
|
129
129
|
infinite-scroll feeds and very large tables:
|
|
130
130
|
|
|
131
|
-
```typescript
|
|
131
|
+
```typescript fragment
|
|
132
132
|
// in a controller
|
|
133
133
|
let result = await Post.query().cursorPaginate({ limit: 20 });
|
|
134
134
|
|
|
@@ -141,7 +141,7 @@ result.hasMore; // boolean
|
|
|
141
141
|
const more = await Post.query().cursorPaginate({ cursor: result.nextCursor!, limit: 20 });
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
```typescript
|
|
144
|
+
```typescript fragment
|
|
145
145
|
async cursorPaginate<T>(options?: { cursor?: number; limit?: number }): Promise<CursorPaginateResult<T>>
|
|
146
146
|
```
|
|
147
147
|
|
|
@@ -157,7 +157,7 @@ that encodes the last row's sort value so clients cannot interpret or tamper wit
|
|
|
157
157
|
it. A secondary `id ASC` tiebreaker keeps page boundaries stable when the sort
|
|
158
158
|
column isn't unique.
|
|
159
159
|
|
|
160
|
-
```typescript
|
|
160
|
+
```typescript fragment
|
|
161
161
|
// in a controller
|
|
162
162
|
// First page, newest first
|
|
163
163
|
const p1 = await Post.query().keysetPaginate({ column: "created_at", direction: "desc" });
|
|
@@ -173,7 +173,7 @@ const p2 = await Post.query().keysetPaginate({
|
|
|
173
173
|
});
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
```typescript
|
|
176
|
+
```typescript fragment
|
|
177
177
|
async keysetPaginate<T>(options?: KeysetOptions): Promise<KeysetPaginateResult<T>>
|
|
178
178
|
|
|
179
179
|
interface KeysetOptions {
|
|
@@ -213,7 +213,7 @@ Paginate _after_ applying filters and sorting, then pass the current filters to
|
|
|
213
213
|
`links()` so they ride along on every page URL — without that, clicking "page 2" would
|
|
214
214
|
silently drop the user's search:
|
|
215
215
|
|
|
216
|
-
```typescript
|
|
216
|
+
```typescript fragment
|
|
217
217
|
// in a controller — GET /search?q=bun&page=2
|
|
218
218
|
const q = http.query("q", "");
|
|
219
219
|
|
|
@@ -235,7 +235,7 @@ return view("search", {
|
|
|
235
235
|
For a feed the client scrolls forever, return the rows plus the next cursor and nothing
|
|
236
236
|
else — no count, no page numbers. The client sends the cursor back to fetch more:
|
|
237
237
|
|
|
238
|
-
```typescript
|
|
238
|
+
```typescript fragment
|
|
239
239
|
// in a controller — GET /api/posts?cursor=128
|
|
240
240
|
const result = await Post.query()
|
|
241
241
|
.with("author")
|
|
@@ -258,7 +258,7 @@ live at the edges, so test the boundaries rather than the happy page.
|
|
|
258
258
|
A `paginate()` result carries `data` plus the counts, so one call proves several
|
|
259
259
|
things at once:
|
|
260
260
|
|
|
261
|
-
```typescript
|
|
261
|
+
```typescript fragment
|
|
262
262
|
// tests/pagination/Posts.test.ts
|
|
263
263
|
import { test, expect } from "bun:test";
|
|
264
264
|
import { PostFactory } from "../../database/factories/PostFactory.ts";
|
|
@@ -280,7 +280,7 @@ test("reports the right totals on the last page", async () => {
|
|
|
280
280
|
**The three cases worth pinning down** are the ones that produce a broken UI
|
|
281
281
|
rather than an exception:
|
|
282
282
|
|
|
283
|
-
```typescript
|
|
283
|
+
```typescript fragment
|
|
284
284
|
// tests/pagination/Posts.test.ts
|
|
285
285
|
test("an empty result reports null bounds, not zero", async () => {
|
|
286
286
|
const page = await Post.query().where("status", "nothing").paginate();
|
package/docs/password-reset.md
CHANGED
|
@@ -35,7 +35,7 @@ The broker reads and writes through your query functions, so it needs a table to
|
|
|
35
35
|
back them. The default examples below use a `password_reset_tokens` table keyed by
|
|
36
36
|
email:
|
|
37
37
|
|
|
38
|
-
```typescript
|
|
38
|
+
```typescript fragment
|
|
39
39
|
// database/migrations/xxxx_create_password_reset_tokens.ts
|
|
40
40
|
await Schema.create("password_reset_tokens", (table) => {
|
|
41
41
|
table.string("email").primary();
|
|
@@ -54,7 +54,7 @@ Create the broker once and export it so controllers can import it. Pass an objec
|
|
|
54
54
|
implementing `PasswordBrokerOptions` — each query function maps the broker onto your
|
|
55
55
|
storage:
|
|
56
56
|
|
|
57
|
-
```typescript
|
|
57
|
+
```typescript fragment
|
|
58
58
|
// app/auth/passwords.ts
|
|
59
59
|
import { PasswordBroker, Hash } from "@zerotal/auth";
|
|
60
60
|
import { Notify } from "@zerotal/notifications";
|
|
@@ -136,7 +136,7 @@ The controller wires the broker into request handlers: one pair for requesting a
|
|
|
136
136
|
link, one pair for submitting the new password. Compare the broker result against the
|
|
137
137
|
[`PASSWORDS` constants](#passwords-constants) rather than raw strings.
|
|
138
138
|
|
|
139
|
-
```typescript
|
|
139
|
+
```typescript fragment
|
|
140
140
|
// app/controllers/PasswordResetController.ts
|
|
141
141
|
import { broker } from "#app/auth/passwords.ts";
|
|
142
142
|
import { Auth, PASSWORDS } from "@zerotal/auth";
|
|
@@ -199,7 +199,7 @@ export class PasswordResetController {
|
|
|
199
199
|
|
|
200
200
|
## Routes
|
|
201
201
|
|
|
202
|
-
```typescript
|
|
202
|
+
```typescript fragment
|
|
203
203
|
// routes/web.ts
|
|
204
204
|
import { GuestMiddleware } from "@zerotal/auth";
|
|
205
205
|
import { PasswordResetController } from "#app/controllers/PasswordResetController.ts";
|
|
@@ -220,7 +220,7 @@ Expired rows accumulate because `reset()` only deletes a token when it's used or
|
|
|
220
220
|
found expired on lookup. Call `broker.prune()` on a schedule to clear the rest — it
|
|
221
221
|
delegates to your `pruneTokens` function with the cutoff date:
|
|
222
222
|
|
|
223
|
-
```typescript
|
|
223
|
+
```typescript fragment
|
|
224
224
|
// app/schedules/PrunePasswordTokens.ts
|
|
225
225
|
import { Schedule } from "@zerotal/scheduler";
|
|
226
226
|
import { broker } from "#app/auth/passwords.ts";
|
|
@@ -292,7 +292,7 @@ earn a test: a wrong token, an expired one, and the replay above.
|
|
|
292
292
|
On the HTTP side, the case worth pinning down is that a miss is indistinguishable
|
|
293
293
|
from a hit:
|
|
294
294
|
|
|
295
|
-
```typescript
|
|
295
|
+
```typescript fragment
|
|
296
296
|
// tests/http/password-reset.test.ts
|
|
297
297
|
const res = await app.post("/forgot-password", { email: "nobody@example.com" });
|
|
298
298
|
|
package/docs/providers.md
CHANGED
|
@@ -53,7 +53,7 @@ import { ServiceProvider } from "zerotal";
|
|
|
53
53
|
|
|
54
54
|
## Anatomy of a provider
|
|
55
55
|
|
|
56
|
-
```typescript
|
|
56
|
+
```typescript fragment
|
|
57
57
|
// app/providers/AppServiceProvider.ts
|
|
58
58
|
import { ServiceProvider } from "zerotal";
|
|
59
59
|
import { PaymentGateway } from "../services/PaymentGateway.ts";
|
|
@@ -107,7 +107,7 @@ one provider must be fully prepared before another even registers. There are als
|
|
|
107
107
|
List providers in `bootstrap/providers.ts`. Order matters — a provider can
|
|
108
108
|
only resolve bindings registered by providers that appear earlier in the list:
|
|
109
109
|
|
|
110
|
-
```typescript
|
|
110
|
+
```typescript fragment
|
|
111
111
|
// bootstrap/providers.ts
|
|
112
112
|
import { DatabaseProvider } from "@zerotal/orm";
|
|
113
113
|
import { CacheProvider } from "@zerotal/cache";
|
|
@@ -157,7 +157,7 @@ type-checked and survives renames. It does two jobs at once:
|
|
|
157
157
|
|
|
158
158
|
So an app that uses the admin panel only needs the panel itself:
|
|
159
159
|
|
|
160
|
-
```typescript
|
|
160
|
+
```typescript fragment
|
|
161
161
|
// bootstrap/providers.ts — FlowProvider arrives via AdminProvider.dependsOn
|
|
162
162
|
const providers = [AdminProvider];
|
|
163
163
|
|
|
@@ -180,7 +180,7 @@ defaults to `0`), then to registration order. It's a coarse knob — useful for
|
|
|
180
180
|
framework-core provider that should generally boot ahead of everything else,
|
|
181
181
|
without every other provider having to name it explicitly:
|
|
182
182
|
|
|
183
|
-
```typescript
|
|
183
|
+
```typescript fragment
|
|
184
184
|
export class CoreProvider extends ServiceProvider {
|
|
185
185
|
static priority = -100; // boots before ordinary (priority 0) providers
|
|
186
186
|
}
|
|
@@ -214,7 +214,7 @@ is **synchronous** — no `await` — and runs before any binding is resolved, s
|
|
|
214
214
|
treat it as pure wiring. The factory closures you pass don't run yet; they run
|
|
215
215
|
_later_, when the binding is first resolved.
|
|
216
216
|
|
|
217
|
-
```typescript
|
|
217
|
+
```typescript fragment
|
|
218
218
|
// inside a ServiceProvider
|
|
219
219
|
onRegister(): void {
|
|
220
220
|
// Singleton — one shared instance per app lifetime
|
|
@@ -243,7 +243,7 @@ booted. Reach for it only when one provider must finish preparing before the nex
|
|
|
243
243
|
one even registers — most providers skip it entirely. It's async, so you can
|
|
244
244
|
`await`:
|
|
245
245
|
|
|
246
|
-
```typescript
|
|
246
|
+
```typescript fragment
|
|
247
247
|
// inside a ServiceProvider
|
|
248
248
|
async onBooting(): Promise<void> {
|
|
249
249
|
// e.g. open a connection pool that a provider listed after this one
|
|
@@ -259,7 +259,7 @@ By `onBooted()` every provider has registered, so this is the safe place to
|
|
|
259
259
|
_resolve_ bindings (including from other providers), start background work, and
|
|
260
260
|
register event listeners. It runs in parallel across all providers:
|
|
261
261
|
|
|
262
|
-
```typescript
|
|
262
|
+
```typescript fragment
|
|
263
263
|
// inside a ServiceProvider
|
|
264
264
|
async onBooted(): Promise<void> {
|
|
265
265
|
// Resolve a binding from another provider
|
|
@@ -299,7 +299,7 @@ A provider can add its own auto-discovered directory by registering a concern de
|
|
|
299
299
|
`onRegister()`/`onBooting()`. The framework scans the directory at boot and calls `register()`
|
|
300
300
|
for each file's exports:
|
|
301
301
|
|
|
302
|
-
```typescript
|
|
302
|
+
```typescript fragment
|
|
303
303
|
// inside a ServiceProvider
|
|
304
304
|
onRegister(): void {
|
|
305
305
|
this.app.registerConcern({
|
|
@@ -346,7 +346,7 @@ Second — and this is the part that actually defers it — register it with
|
|
|
346
346
|
`app.defer()` instead of the normal providers array. `static provides` on its own
|
|
347
347
|
is just metadata; it's `defer()` that wires the lazy boot:
|
|
348
348
|
|
|
349
|
-
```typescript
|
|
349
|
+
```typescript fragment
|
|
350
350
|
// bootstrap/app.ts
|
|
351
351
|
const app = Application.create({ providers });
|
|
352
352
|
app.defer([SearchProvider]); // array form reads each provider's `static provides`
|
|
@@ -366,7 +366,7 @@ A provider runs in every runtime by default. Branch on `this.app.environment` to
|
|
|
366
366
|
bind a different implementation per environment — a fake mailer under `test`, the
|
|
367
367
|
real one everywhere else:
|
|
368
368
|
|
|
369
|
-
```typescript
|
|
369
|
+
```typescript fragment
|
|
370
370
|
// inside a ServiceProvider
|
|
371
371
|
onRegister(): void {
|
|
372
372
|
if (this.app.environment === 'test') {
|
|
@@ -387,7 +387,7 @@ Providers can push middleware into the global pipeline via `this.app.useOnce()`
|
|
|
387
387
|
the framework guarantees it's added exactly once, even if the same middleware is
|
|
388
388
|
registered by several providers:
|
|
389
389
|
|
|
390
|
-
```typescript
|
|
390
|
+
```typescript fragment
|
|
391
391
|
// inside a ServiceProvider
|
|
392
392
|
onBooting(): Promise<void> {
|
|
393
393
|
this.app.useOnce(SessionMiddleware);
|
package/docs/query-builder.md
CHANGED
|
@@ -53,7 +53,7 @@ just a description of a query until you `await` it.
|
|
|
53
53
|
|
|
54
54
|
## Selecting columns
|
|
55
55
|
|
|
56
|
-
```typescript
|
|
56
|
+
```typescript fragment
|
|
57
57
|
// in a controller or service
|
|
58
58
|
DB.table("posts").select("id", "title", "created_at");
|
|
59
59
|
DB.table("posts").distinct().select("status");
|
|
@@ -65,7 +65,7 @@ so build it only from trusted constants.
|
|
|
65
65
|
|
|
66
66
|
## Where clauses
|
|
67
67
|
|
|
68
|
-
```typescript
|
|
68
|
+
```typescript fragment
|
|
69
69
|
// in a controller or service
|
|
70
70
|
DB.table("posts").where("status", "published"); // column = value
|
|
71
71
|
DB.table("posts").where("views", ">", 1000); // explicit operator
|
|
@@ -106,7 +106,7 @@ parameterized.
|
|
|
106
106
|
|
|
107
107
|
## Joins
|
|
108
108
|
|
|
109
|
-
```typescript
|
|
109
|
+
```typescript fragment
|
|
110
110
|
// in a controller or service
|
|
111
111
|
DB.table("posts")
|
|
112
112
|
.join("users", "posts.user_id", "=", "users.id")
|
|
@@ -122,7 +122,7 @@ alias, first, operator, second)`.
|
|
|
122
122
|
|
|
123
123
|
## Ordering, grouping, limits
|
|
124
124
|
|
|
125
|
-
```typescript
|
|
125
|
+
```typescript fragment
|
|
126
126
|
// in a controller or service
|
|
127
127
|
DB.table("posts").orderBy("created_at", "desc");
|
|
128
128
|
DB.table("posts").orderByDesc("created_at"); // shorthand for the line above
|
|
@@ -145,7 +145,7 @@ clauses and optionally applies a fresh one.
|
|
|
145
145
|
Apply clauses only when a condition is truthy — handy for optional filters without
|
|
146
146
|
breaking the chain:
|
|
147
147
|
|
|
148
|
-
```typescript
|
|
148
|
+
```typescript fragment
|
|
149
149
|
// in a controller or service
|
|
150
150
|
DB.table("posts")
|
|
151
151
|
.when(status, (q, value) => q.where("status", value))
|
|
@@ -157,7 +157,7 @@ The callback receives the builder and the (truthy) condition value.
|
|
|
157
157
|
|
|
158
158
|
## Retrieving results
|
|
159
159
|
|
|
160
|
-
```typescript
|
|
160
|
+
```typescript fragment
|
|
161
161
|
// in a controller or service
|
|
162
162
|
const rows = await DB.table("posts").where("status", "published").get();
|
|
163
163
|
const row = await DB.table("posts").where("id", 1).first(); // first row or null
|
|
@@ -171,7 +171,7 @@ const has = await DB.table("posts").where("user_id", userId).exists(); // boolea
|
|
|
171
171
|
|
|
172
172
|
Pass a row type to `get<T>()` / `first<T>()` for typed results:
|
|
173
173
|
|
|
174
|
-
```typescript
|
|
174
|
+
```typescript fragment
|
|
175
175
|
// in a controller or service
|
|
176
176
|
const rows = await DB.table("posts").get<{ id: number; title: string }>();
|
|
177
177
|
```
|
|
@@ -181,7 +181,7 @@ const rows = await DB.table("posts").get<{ id: number; title: string }>();
|
|
|
181
181
|
|
|
182
182
|
### Aggregates
|
|
183
183
|
|
|
184
|
-
```typescript
|
|
184
|
+
```typescript fragment
|
|
185
185
|
// in a controller or service
|
|
186
186
|
await DB.table("posts").count();
|
|
187
187
|
await DB.table("posts").where("status", "published").sum("views");
|
|
@@ -197,7 +197,7 @@ For paged result sets, see [Pagination](/docs/pagination).
|
|
|
197
197
|
For result sets too large to hold in memory, page through them instead of calling
|
|
198
198
|
`get()`:
|
|
199
199
|
|
|
200
|
-
```typescript
|
|
200
|
+
```typescript fragment
|
|
201
201
|
// in a command or job
|
|
202
202
|
await DB.table("posts").chunk(500, async (rows, page) => {
|
|
203
203
|
for (const row of rows) await archive(row);
|
|
@@ -215,7 +215,7 @@ for await (const row of DB.table("posts").lazy()) {
|
|
|
215
215
|
|
|
216
216
|
## Writing rows
|
|
217
217
|
|
|
218
|
-
```typescript
|
|
218
|
+
```typescript fragment
|
|
219
219
|
// in a controller or service
|
|
220
220
|
// INSERT
|
|
221
221
|
await DB.table("post_tags").insert({ post_id: 1, tag_id: 3 });
|
|
@@ -242,7 +242,7 @@ one if none exists; it returns `true` when a row was inserted.
|
|
|
242
242
|
|
|
243
243
|
Inside a [transaction](/docs/database#transactions), lock the selected rows:
|
|
244
244
|
|
|
245
|
-
```typescript
|
|
245
|
+
```typescript fragment
|
|
246
246
|
// in a controller or service
|
|
247
247
|
await DB.transaction(async (trx) => {
|
|
248
248
|
const row = await trx.table("accounts").where("id", 1).lockForUpdate().first();
|
|
@@ -260,7 +260,7 @@ await DB.transaction(async (trx) => {
|
|
|
260
260
|
|
|
261
261
|
## Debugging
|
|
262
262
|
|
|
263
|
-
```typescript
|
|
263
|
+
```typescript fragment
|
|
264
264
|
// in a controller or service
|
|
265
265
|
DB.table("posts").where("status", "published").toSql(); // SQL with ? placeholders
|
|
266
266
|
DB.table("posts").where("status", "published").toRawSql(); // values inlined (logging only)
|
|
@@ -282,7 +282,7 @@ The bread and butter of any index page or list endpoint. `when()` lets every fil
|
|
|
282
282
|
be optional without a tangle of `if` statements, and `paginate()` returns the rows
|
|
283
283
|
plus the page metadata in one call:
|
|
284
284
|
|
|
285
|
-
```typescript
|
|
285
|
+
```typescript fragment
|
|
286
286
|
// in a controller — req.query holds the optional filters
|
|
287
287
|
const posts = await DB.table("posts")
|
|
288
288
|
.when(req.query.status, (q, status) => q.where("status", status))
|
|
@@ -301,7 +301,7 @@ For join tables and key/value rows, `updateOrInsert` avoids the "check then inse
|
|
|
301
301
|
race — it updates the row matching the first argument, or inserts the two merged if
|
|
302
302
|
none exists:
|
|
303
303
|
|
|
304
|
-
```typescript
|
|
304
|
+
```typescript fragment
|
|
305
305
|
await DB.table("user_settings").updateOrInsert(
|
|
306
306
|
{ user_id: userId, key: "theme" }, // how to find the row
|
|
307
307
|
{ value: "dark" }, // what to set
|
|
@@ -313,7 +313,7 @@ await DB.table("user_settings").updateOrInsert(
|
|
|
313
313
|
When you only need numbers, skip model hydration entirely and let the database do the
|
|
314
314
|
aggregation:
|
|
315
315
|
|
|
316
|
-
```typescript
|
|
316
|
+
```typescript fragment
|
|
317
317
|
const byAuthor = await DB.table("posts")
|
|
318
318
|
.select("user_id")
|
|
319
319
|
.selectRaw("COUNT(*) AS post_count")
|
|
@@ -329,7 +329,7 @@ const byAuthor = await DB.table("posts")
|
|
|
329
329
|
Never load a big table with `get()`. Page through it with `chunkById`, which walks an
|
|
330
330
|
incrementing key so concurrent inserts or deletes can't make it skip or repeat rows:
|
|
331
331
|
|
|
332
|
-
```typescript
|
|
332
|
+
```typescript fragment
|
|
333
333
|
// in a command or job
|
|
334
334
|
await DB.table("posts")
|
|
335
335
|
.whereNull("slug")
|
|
@@ -347,7 +347,7 @@ await DB.table("posts")
|
|
|
347
347
|
When exactly one row should match — looking a user up by email, say — `sole()` turns
|
|
348
348
|
"zero or many matches" into a thrown error instead of a silent bug:
|
|
349
349
|
|
|
350
|
-
```typescript
|
|
350
|
+
```typescript fragment
|
|
351
351
|
const user = await DB.table("users").where("email", email).sole();
|
|
352
352
|
```
|
|
353
353
|
|
|
@@ -383,7 +383,7 @@ silently dropped by a mis-chained `orWhere` — without needing rows to prove it
|
|
|
383
383
|
**Assert the rows when the result is the point.** Arrange with factories and run
|
|
384
384
|
the query for real:
|
|
385
385
|
|
|
386
|
-
```typescript
|
|
386
|
+
```typescript fragment
|
|
387
387
|
// tests/queries/ActiveSubscribers.test.ts
|
|
388
388
|
test("excludes users still in trial", async () => {
|
|
389
389
|
await UserFactory.create({ status: "active", trialEndsAt: null });
|
package/docs/queue.md
CHANGED
|
@@ -56,7 +56,9 @@ import { QueueConfig } from "@zerotal/queue";
|
|
|
56
56
|
import { env } from "zerotal";
|
|
57
57
|
|
|
58
58
|
export default QueueConfig({
|
|
59
|
-
|
|
59
|
+
// One of 'sqlite' | 'redis' | 'sync', written literally: the field is that union
|
|
60
|
+
// and `env()` returns a plain string.
|
|
61
|
+
driver: "sqlite",
|
|
60
62
|
pollInterval: env("QUEUE_POLL_INTERVAL", 500),
|
|
61
63
|
queues: ["default"],
|
|
62
64
|
workers: env("QUEUE_WORKERS", 0),
|
|
@@ -81,7 +83,7 @@ A job is a class that extends `Job` and implements `handle()`. Constructor
|
|
|
81
83
|
arguments are the job's state — serialise them in `payload()` and restore them in
|
|
82
84
|
a static `fromPayload()`:
|
|
83
85
|
|
|
84
|
-
```typescript
|
|
86
|
+
```typescript fragment
|
|
85
87
|
// app/jobs/NotifyFollowersJob.ts
|
|
86
88
|
import { Job, JobRegistry } from "@zerotal/queue";
|
|
87
89
|
|
|
@@ -126,7 +128,7 @@ JobRegistry.register(NotifyFollowersJob as never);
|
|
|
126
128
|
|
|
127
129
|
A job with no constructor state needs only `handle()` plus the registration line:
|
|
128
130
|
|
|
129
|
-
```typescript
|
|
131
|
+
```typescript fragment
|
|
130
132
|
// app/jobs/PruneDeletedContentJob.ts
|
|
131
133
|
import { Job, JobRegistry } from "@zerotal/queue";
|
|
132
134
|
|
|
@@ -164,7 +166,7 @@ the `JobRegistry.register(...)` line — keep it at the bottom of each job file.
|
|
|
164
166
|
|
|
165
167
|
## Dispatching jobs
|
|
166
168
|
|
|
167
|
-
```typescript
|
|
169
|
+
```typescript fragment
|
|
168
170
|
// in a controller
|
|
169
171
|
import { Queue } from "@zerotal/queue";
|
|
170
172
|
|
|
@@ -183,7 +185,7 @@ worker loop, not the web request.
|
|
|
183
185
|
|
|
184
186
|
A document saved eight times in a minute should rebuild its search index once, and the only rebuild anyone sees is the last one. Set `debounce` to a number of seconds and repeated dispatches collapse into a single run:
|
|
185
187
|
|
|
186
|
-
```typescript
|
|
188
|
+
```typescript fragment
|
|
187
189
|
export class ReindexDocument extends Job {
|
|
188
190
|
/** Run 30s after the last dispatch, not once per dispatch. */
|
|
189
191
|
override readonly debounce = 30;
|
|
@@ -202,7 +204,7 @@ export class ReindexDocument extends Job {
|
|
|
202
204
|
}
|
|
203
205
|
```
|
|
204
206
|
|
|
205
|
-
```typescript
|
|
207
|
+
```typescript fragment
|
|
206
208
|
// Eight saves in quick succession…
|
|
207
209
|
for (const _ of edits) await Queue.dispatch(new ReindexDocument(doc.id));
|
|
208
210
|
// …one job, running 30s after the last one.
|
|
@@ -220,7 +222,7 @@ By default, the class name plus the serialised payload. So `ReindexDocument(1)`
|
|
|
220
222
|
|
|
221
223
|
Override `debounceKey()` when two payloads mean the same work. A job carrying a timestamp or a request id is unique on every dispatch and would otherwise never collapse with anything:
|
|
222
224
|
|
|
223
|
-
```typescript
|
|
225
|
+
```typescript fragment
|
|
224
226
|
export class ReindexDocument extends Job {
|
|
225
227
|
override readonly debounce = 30;
|
|
226
228
|
|
|
@@ -258,7 +260,7 @@ This is the behaviour you want for the reindex case: a save that lands while the
|
|
|
258
260
|
Batch a set of jobs and react when they all finish. Batching uses the
|
|
259
261
|
`zerotal_job_batches` table (auto-created by `SqliteDriver`).
|
|
260
262
|
|
|
261
|
-
```typescript
|
|
263
|
+
```typescript fragment
|
|
262
264
|
// in a controller
|
|
263
265
|
import { Bus } from "@zerotal/queue";
|
|
264
266
|
|
|
@@ -283,7 +285,7 @@ const batch = await Bus.batch(rows.map((row) => new ImportCsvRowJob(row)))
|
|
|
283
285
|
|
|
284
286
|
`Bus.batch(...).dispatch()` resolves to a `Batch` instance:
|
|
285
287
|
|
|
286
|
-
```typescript
|
|
288
|
+
```typescript fragment
|
|
287
289
|
// after .dispatch()
|
|
288
290
|
batch.id; // UUID string
|
|
289
291
|
batch.name; // label from .name()
|
|
@@ -304,7 +306,7 @@ batch.progress(); // 0.0 → 1.0
|
|
|
304
306
|
Run jobs sequentially: each job dispatches the next one only after it succeeds. If
|
|
305
307
|
any job fails, the rest of the chain is abandoned.
|
|
306
308
|
|
|
307
|
-
```typescript
|
|
309
|
+
```typescript fragment
|
|
308
310
|
// in a controller
|
|
309
311
|
import { Bus } from "@zerotal/queue";
|
|
310
312
|
|
|
@@ -437,7 +439,7 @@ which stops every thread. Any in-flight or queued work is resolved with
|
|
|
437
439
|
`QueueFake` swaps the `queue` binding for a fake that captures dispatched jobs
|
|
438
440
|
instead of running them, so you can assert on them:
|
|
439
441
|
|
|
440
|
-
```typescript
|
|
442
|
+
```typescript fragment
|
|
441
443
|
// in a test
|
|
442
444
|
import { QueueFake } from "@zerotal/queue";
|
|
443
445
|
|
|
@@ -530,7 +532,7 @@ while leaving unrelated failures alone.
|
|
|
530
532
|
| `QueueShuttingDownError` | `E_QUEUE_SHUTTING_DOWN` | Dispatching during a graceful shutdown — the manager is draining. |
|
|
531
533
|
| `QueueBatchingUnsupportedError` | `E_QUEUE_BATCHING_UNSUPPORTED` | Using batches on a driver that has no batch support. |
|
|
532
534
|
|
|
533
|
-
```typescript
|
|
535
|
+
```typescript fragment
|
|
534
536
|
// in a controller or service
|
|
535
537
|
import { QueueError, QueueShuttingDownError } from "@zerotal/queue";
|
|
536
538
|
|
package/docs/rate-limiting.md
CHANGED
|
@@ -38,7 +38,7 @@ import { ThrottleMiddleware } from "zerotal";
|
|
|
38
38
|
Attach it directly with the static `.with(options)` factory, which returns a
|
|
39
39
|
ready-to-use middleware class:
|
|
40
40
|
|
|
41
|
-
```typescript
|
|
41
|
+
```typescript fragment
|
|
42
42
|
// in routes/web.ts (or wherever you register routes)
|
|
43
43
|
import { ThrottleMiddleware } from "zerotal";
|
|
44
44
|
|
|
@@ -117,7 +117,7 @@ is the client IP.
|
|
|
117
117
|
`RateLimiter.middleware(name)` returns the middleware instance for a registered
|
|
118
118
|
limiter, ready to drop into a route or group:
|
|
119
119
|
|
|
120
|
-
```typescript
|
|
120
|
+
```typescript fragment
|
|
121
121
|
// in routes/web.ts
|
|
122
122
|
import { RateLimiter } from "zerotal";
|
|
123
123
|
|
|
@@ -133,7 +133,7 @@ Router.group({ prefix: "/api", middleware: [RateLimiter.middleware("api")] }, ()
|
|
|
133
133
|
Check or clear a limiter imperatively — e.g. reset failed-login counts after a
|
|
134
134
|
successful sign-in:
|
|
135
135
|
|
|
136
|
-
```typescript
|
|
136
|
+
```typescript fragment
|
|
137
137
|
// in a controller
|
|
138
138
|
import { RateLimiter } from "zerotal";
|
|
139
139
|
|
|
@@ -171,7 +171,7 @@ line for CLI requests.
|
|
|
171
171
|
Set your suite up once as described in [Testing](/docs/testing). A rate limiter
|
|
172
172
|
is only proven by the request that gets refused, so the test has to exhaust it.
|
|
173
173
|
|
|
174
|
-
```typescript
|
|
174
|
+
```typescript fragment
|
|
175
175
|
// tests/http/throttle.test.ts
|
|
176
176
|
import { test, expect } from "bun:test";
|
|
177
177
|
import { createApp } from "../helpers.ts";
|
|
@@ -196,7 +196,7 @@ test("the sixth attempt in a minute is refused", async () => {
|
|
|
196
196
|
reads to back off. A limiter that returns `429` without them still fails the
|
|
197
197
|
clients it was meant to protect you from:
|
|
198
198
|
|
|
199
|
-
```typescript
|
|
199
|
+
```typescript fragment
|
|
200
200
|
// tests/http/throttle.test.ts
|
|
201
201
|
blocked.assertHeader("X-RateLimit-Limit", "5");
|
|
202
202
|
blocked.assertHeader("X-RateLimit-Remaining", "0");
|
|
@@ -206,7 +206,7 @@ blocked.assertHeader("X-RateLimit-Remaining", "0");
|
|
|
206
206
|
next in the same window. Give each test a distinct key — a different route, IP
|
|
207
207
|
header, or user — rather than relying on ordering:
|
|
208
208
|
|
|
209
|
-
```typescript
|
|
209
|
+
```typescript fragment
|
|
210
210
|
// tests/http/throttle.test.ts
|
|
211
211
|
await app.post("/login", { email: "a@b.c" }, { "X-Forwarded-For": "10.0.0.7" });
|
|
212
212
|
```
|