@zerotal/arch 1.7.3 → 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/api-surface.md +4 -4
- 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 +165 -1
- package/docs/client/index.md +277 -70
- 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 +46 -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 +199 -0
- 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 +12 -7
- package/docs/inertia/props.md +22 -22
- package/docs/inertia/rendering.md +86 -7
- 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 +36 -8
- 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/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
package/docs/inertia/ssr.md
CHANGED
|
@@ -60,7 +60,7 @@ streams the React output between the template's HTML prefix and suffix:
|
|
|
60
60
|
|
|
61
61
|
Swap `inertia()` → `inertiaStream()` and `await` it — nothing else changes:
|
|
62
62
|
|
|
63
|
-
```ts
|
|
63
|
+
```ts fragment
|
|
64
64
|
// app/controllers/PostController.ts
|
|
65
65
|
import { inertiaStream } from "@zerotal/inertia";
|
|
66
66
|
|
|
@@ -83,7 +83,7 @@ traversal (`..` or a leading `/`).
|
|
|
83
83
|
|
|
84
84
|
### inertia vs. inertiaStream
|
|
85
85
|
|
|
86
|
-
|
|
|
86
|
+
| Criterion | `inertia()` | `inertiaStream()` |
|
|
87
87
|
| -------------- | ---------------------- | ---------------------------------- |
|
|
88
88
|
| Return type | `Promise<void>` | `Promise<void>` |
|
|
89
89
|
| Rendering | Buffered HTML string | Streaming `renderToReadableStream` |
|
package/docs/lifecycle.md
CHANGED
|
@@ -244,7 +244,7 @@ the pipeline sets `ctx.response`), and `onResponseSent()` (after the response is
|
|
|
244
244
|
sent). The full phase reference lives in
|
|
245
245
|
[The Application](/docs/application) and [Service Providers](/docs/providers).
|
|
246
246
|
|
|
247
|
-
```typescript
|
|
247
|
+
```typescript fragment
|
|
248
248
|
// app/providers/PaymentProvider.ts
|
|
249
249
|
import { ServiceProvider } from "zerotal";
|
|
250
250
|
|
|
@@ -303,7 +303,7 @@ Boot and shutdown phases in order, and the per-request hook surface.
|
|
|
303
303
|
`app.bootDurationMs` is the total. `app.providerReport` is the breakdown — one
|
|
304
304
|
`ProviderReport` per provider, in boot order:
|
|
305
305
|
|
|
306
|
-
```typescript
|
|
306
|
+
```typescript fragment
|
|
307
307
|
for (const { name, durationMs, bindings } of app.providerReport) {
|
|
308
308
|
console.log(`${name} ${durationMs}ms, bound ${bindings.length}`);
|
|
309
309
|
}
|
package/docs/lock.md
CHANGED
|
@@ -90,7 +90,7 @@ The `Lock` facade resolves the live `LockManager` from the container on every
|
|
|
90
90
|
call. `Lock.try` acquires once, runs your callback, and always releases — even if
|
|
91
91
|
the callback throws:
|
|
92
92
|
|
|
93
|
-
```ts
|
|
93
|
+
```ts fragment
|
|
94
94
|
// in a controller or service
|
|
95
95
|
import { Lock } from "zerotal/lock";
|
|
96
96
|
|
|
@@ -111,7 +111,7 @@ crashes.
|
|
|
111
111
|
Acquire once, run the callback, release. Throws `LockNotAcquiredError`
|
|
112
112
|
immediately if the lock is already held:
|
|
113
113
|
|
|
114
|
-
```ts
|
|
114
|
+
```ts fragment
|
|
115
115
|
// in a controller
|
|
116
116
|
import { Lock, LockNotAcquiredError } from "zerotal/lock";
|
|
117
117
|
|
|
@@ -133,7 +133,7 @@ try {
|
|
|
133
133
|
Wait up to `options.timeout` seconds for the lock to become free, then run the
|
|
134
134
|
callback:
|
|
135
135
|
|
|
136
|
-
```ts
|
|
136
|
+
```ts fragment
|
|
137
137
|
// in a service
|
|
138
138
|
import { Lock } from "zerotal/lock";
|
|
139
139
|
|
|
@@ -158,7 +158,7 @@ For complex flows where you need explicit acquire/release control. `Lock.make`
|
|
|
158
158
|
returns a `ManagedLock` but does **not** acquire it — call `.acquire()` or
|
|
159
159
|
`.block()` yourself:
|
|
160
160
|
|
|
161
|
-
```ts
|
|
161
|
+
```ts fragment
|
|
162
162
|
// in a service
|
|
163
163
|
import { Lock } from "zerotal/lock";
|
|
164
164
|
|
|
@@ -205,7 +205,7 @@ different questions at once.
|
|
|
205
205
|
Refreshing separates them. Pass `refresh: true` and the lock is extended in the
|
|
206
206
|
background for as long as the callback runs:
|
|
207
207
|
|
|
208
|
-
```typescript
|
|
208
|
+
```typescript fragment
|
|
209
209
|
await Lock.block(
|
|
210
210
|
"report:monthly",
|
|
211
211
|
60,
|
|
@@ -232,7 +232,7 @@ believing they are the only one.
|
|
|
232
232
|
|
|
233
233
|
So the callback's `AbortSignal` is aborted and `LockLostError` is thrown:
|
|
234
234
|
|
|
235
|
-
```typescript
|
|
235
|
+
```typescript fragment
|
|
236
236
|
try {
|
|
237
237
|
await Lock.block("report:monthly", 60, run, { refresh: true });
|
|
238
238
|
} catch (err) {
|
|
@@ -257,7 +257,7 @@ still valid, and nothing written before refreshing existed needs to change.
|
|
|
257
257
|
A manual [`ManagedLock`](#lockmake-manual-handle) exposes the same thing directly,
|
|
258
258
|
for flows that span steps rather than sitting inside one callback:
|
|
259
259
|
|
|
260
|
-
```typescript
|
|
260
|
+
```typescript fragment
|
|
261
261
|
const lock = Lock.make("import:batch", 60);
|
|
262
262
|
if (await lock.acquire()) {
|
|
263
263
|
try {
|
|
@@ -286,7 +286,7 @@ it is what `refresh()` does.
|
|
|
286
286
|
|
|
287
287
|
### Idempotent job processing
|
|
288
288
|
|
|
289
|
-
```ts
|
|
289
|
+
```ts fragment
|
|
290
290
|
// app/jobs/ProcessOrderJob.ts
|
|
291
291
|
import { Lock } from "zerotal/lock";
|
|
292
292
|
|
|
@@ -304,7 +304,7 @@ export class ProcessOrderJob extends Job {
|
|
|
304
304
|
|
|
305
305
|
### Rate-limited report generation
|
|
306
306
|
|
|
307
|
-
```ts
|
|
307
|
+
```ts fragment
|
|
308
308
|
// app/controllers/ReportController.ts
|
|
309
309
|
import { Lock, LockNotAcquiredError } from "zerotal/lock";
|
|
310
310
|
|
|
@@ -341,7 +341,7 @@ Contention surfaces as a single typed error, `LockNotAcquiredError`:
|
|
|
341
341
|
|
|
342
342
|
The `Lock` facade re-exports it as `Lock.NotAcquired` for terse catch blocks:
|
|
343
343
|
|
|
344
|
-
```ts
|
|
344
|
+
```ts fragment
|
|
345
345
|
// in a controller
|
|
346
346
|
import { Lock } from "zerotal/lock";
|
|
347
347
|
|
|
@@ -386,7 +386,7 @@ export class MyLockDriver implements LockDriver {
|
|
|
386
386
|
Bind a `LockManager` built around it in a provider that runs **after**
|
|
387
387
|
`LockProvider` (last write wins on the `"lock"` key):
|
|
388
388
|
|
|
389
|
-
```ts
|
|
389
|
+
```ts fragment
|
|
390
390
|
// app/providers/AppServiceProvider.ts
|
|
391
391
|
import { ServiceProvider } from "zerotal";
|
|
392
392
|
import { LockManager } from "zerotal/lock";
|
package/docs/logger.md
CHANGED
|
@@ -91,7 +91,7 @@ export default LoggingConfig({
|
|
|
91
91
|
|
|
92
92
|
### The two sinks
|
|
93
93
|
|
|
94
|
-
```ts
|
|
94
|
+
```ts fragment
|
|
95
95
|
// config/logging.ts
|
|
96
96
|
export default LoggingConfig({
|
|
97
97
|
console: { level: "info", format: "json" }, // or false
|
|
@@ -107,7 +107,7 @@ point: you decide what to _watch_ without deciding what to _keep_.
|
|
|
107
107
|
Turning the file off is reasonable in a container that ships stdout to a
|
|
108
108
|
collector:
|
|
109
109
|
|
|
110
|
-
```ts
|
|
110
|
+
```ts fragment
|
|
111
111
|
// config/logging.ts
|
|
112
112
|
export default LoggingConfig({ file: false });
|
|
113
113
|
```
|
|
@@ -117,7 +117,7 @@ export default LoggingConfig({ file: false });
|
|
|
117
117
|
Channels are _extra_ destinations layered on top of the two sinks, for routing a
|
|
118
118
|
subsystem somewhere specific:
|
|
119
119
|
|
|
120
|
-
```ts
|
|
120
|
+
```ts fragment
|
|
121
121
|
// config/logging.ts
|
|
122
122
|
export default LoggingConfig({
|
|
123
123
|
channels: {
|
|
@@ -159,7 +159,7 @@ Each channel is a `{ driver: … }` entry under `channels`, discriminated by its
|
|
|
159
159
|
|
|
160
160
|
The two sinks already cover the common case, so this is for the extras:
|
|
161
161
|
|
|
162
|
-
```ts
|
|
162
|
+
```ts fragment
|
|
163
163
|
// config/logging.ts
|
|
164
164
|
export default LoggingConfig({
|
|
165
165
|
// Machine-readable terminal output in production, human-readable locally.
|
|
@@ -176,7 +176,7 @@ export default LoggingConfig({
|
|
|
176
176
|
`Log` is a static proxy over the `LogManager` singleton. Use it anywhere —
|
|
177
177
|
controllers, services, commands, event listeners:
|
|
178
178
|
|
|
179
|
-
```ts
|
|
179
|
+
```ts fragment
|
|
180
180
|
// in a controller or service
|
|
181
181
|
import { Log } from "zerotal/logger";
|
|
182
182
|
|
|
@@ -189,7 +189,7 @@ Log.fatal("Database unreachable", { host: dbHost }, err);
|
|
|
189
189
|
|
|
190
190
|
Every level shares the same signature:
|
|
191
191
|
|
|
192
|
-
```ts
|
|
192
|
+
```ts fragment
|
|
193
193
|
// signature — all five levels
|
|
194
194
|
Log.info(message: string, context?: Record<string, unknown>, err?: unknown): void
|
|
195
195
|
```
|
|
@@ -202,7 +202,7 @@ Log.info(message: string, context?: Record<string, unknown>, err?: unknown): voi
|
|
|
202
202
|
`Log.channel(name)` returns a `BoundLogger` that writes to the named channel
|
|
203
203
|
instead of the configured default:
|
|
204
204
|
|
|
205
|
-
```ts
|
|
205
|
+
```ts fragment
|
|
206
206
|
// in a controller or service
|
|
207
207
|
import { Log } from "zerotal/logger";
|
|
208
208
|
|
|
@@ -219,7 +219,7 @@ This lets specific modules write to a dedicated channel without changing
|
|
|
219
219
|
that merges `extra` into every entry. Useful in long-running jobs or to tag a
|
|
220
220
|
group of log lines with a shared identifier:
|
|
221
221
|
|
|
222
|
-
```ts
|
|
222
|
+
```ts fragment
|
|
223
223
|
// in a queue job
|
|
224
224
|
import { Log } from "zerotal/logger";
|
|
225
225
|
|
|
@@ -304,7 +304,7 @@ Past three or four keys, inline context stops being readable — `{"compiled":0,
|
|
|
304
304
|
is a wall the eye slides off. `table()` logs the same data and asks the console
|
|
305
305
|
to draw it in columns:
|
|
306
306
|
|
|
307
|
-
```ts
|
|
307
|
+
```ts fragment
|
|
308
308
|
Log.table("Compile summary", { compiled: 0, cached: 2, injected: 3, runtime: 8, ms: 124 });
|
|
309
309
|
```
|
|
310
310
|
|
|
@@ -322,7 +322,7 @@ Log.table("Compile summary", { compiled: 0, cached: 2, injected: 3, runtime: 8,
|
|
|
322
322
|
Pass a list of objects instead and each key becomes a column, with a header. A
|
|
323
323
|
third argument sets the level, which defaults to `info`:
|
|
324
324
|
|
|
325
|
-
```ts
|
|
325
|
+
```ts fragment
|
|
326
326
|
Log.table(
|
|
327
327
|
"Pages rendering through the runtime",
|
|
328
328
|
[
|
|
@@ -452,7 +452,7 @@ gives you a tap.
|
|
|
452
452
|
**`LogManager.tap()` receives every entry** after enrichment and before it
|
|
453
453
|
reaches a channel, and returns an unsubscribe function:
|
|
454
454
|
|
|
455
|
-
```typescript
|
|
455
|
+
```typescript fragment
|
|
456
456
|
// tests/logging/AuditTrail.test.ts
|
|
457
457
|
import { test, expect, afterEach } from "bun:test";
|
|
458
458
|
import { LogManager, type LogEntry } from "zerotal/logger";
|
|
@@ -480,7 +480,7 @@ starts matching something from a different test.
|
|
|
480
480
|
**Silence the logger in the suite** so a passing run stays readable. The `null`
|
|
481
481
|
driver discards everything:
|
|
482
482
|
|
|
483
|
-
```typescript
|
|
483
|
+
```typescript fragment
|
|
484
484
|
// tests/helpers.ts
|
|
485
485
|
.useConfig({
|
|
486
486
|
logging: { default: "null", channels: { null: { driver: "null" } } },
|
package/docs/media.md
CHANGED
|
@@ -9,7 +9,7 @@ Attach files to a model and let the framework handle the rest — where the byte
|
|
|
9
9
|
live, the database row that points at them, the thumbnails, the URLs, the
|
|
10
10
|
ordering, and the cleanup when the model goes away.
|
|
11
11
|
|
|
12
|
-
```ts
|
|
12
|
+
```ts fragment
|
|
13
13
|
const media = await product.addMedia(await ctx.file("photo")).toCollection("images");
|
|
14
14
|
|
|
15
15
|
media.getUrl(); // the original
|
|
@@ -24,7 +24,7 @@ Install the package and register its provider:
|
|
|
24
24
|
bun add @zerotal/media
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
```ts
|
|
27
|
+
```ts fragment
|
|
28
28
|
// bootstrap/providers.ts
|
|
29
29
|
import { StorageProvider } from "zerotal/storage";
|
|
30
30
|
import { MediaProvider } from "zerotal/media";
|
|
@@ -95,7 +95,7 @@ that silently creates a collection nobody ever reads from.
|
|
|
95
95
|
|
|
96
96
|
## Adding files
|
|
97
97
|
|
|
98
|
-
```ts
|
|
98
|
+
```ts fragment
|
|
99
99
|
// From an upload
|
|
100
100
|
await product.addMedia(await ctx.file("photo")).toCollection("images");
|
|
101
101
|
|
|
@@ -127,7 +127,7 @@ derived the same way.
|
|
|
127
127
|
|
|
128
128
|
## Reading
|
|
129
129
|
|
|
130
|
-
```ts
|
|
130
|
+
```ts fragment
|
|
131
131
|
await product.getMedia("images"); // MediaItem[], in order
|
|
132
132
|
await product.getFirstMedia("images"); // MediaItem | null
|
|
133
133
|
await product.getFirstMediaUrl("images"); // or the collection's fallbackUrl
|
|
@@ -145,7 +145,7 @@ the original rather than to nothing.
|
|
|
145
145
|
|
|
146
146
|
Conversions are declared per collection and generated when a file is added:
|
|
147
147
|
|
|
148
|
-
```ts
|
|
148
|
+
```ts fragment
|
|
149
149
|
conversions: {
|
|
150
150
|
thumb: { width: 200, height: 200, format: "webp", quality: 80 },
|
|
151
151
|
hero: { width: 1600, queued: true },
|
|
@@ -187,7 +187,7 @@ runtime, no native module, nothing to install.
|
|
|
187
187
|
held to the same output dimensions by a shared parity suite, so switching is a
|
|
188
188
|
performance decision rather than a feature one:
|
|
189
189
|
|
|
190
|
-
|
|
|
190
|
+
| Criterion | `bun` (default) | `sharp` |
|
|
191
191
|
| --------------------------- | ----------------------- | -------------------------------- |
|
|
192
192
|
| Install | Nothing | `bun add sharp`, a native module |
|
|
193
193
|
| `fit: "cover"` | Yes | Yes |
|
|
@@ -220,7 +220,7 @@ conversion runs inline — late is better than a thumbnail that never appears.
|
|
|
220
220
|
Set `responsive: true` on a collection to generate a width ladder plus an inline
|
|
221
221
|
blur placeholder:
|
|
222
222
|
|
|
223
|
-
```tsx
|
|
223
|
+
```tsx fragment
|
|
224
224
|
<img
|
|
225
225
|
src={media.getUrl()}
|
|
226
226
|
srcset={media.srcset()}
|
|
@@ -237,7 +237,7 @@ needed.
|
|
|
237
237
|
|
|
238
238
|
Any JSON you attach travels with the row:
|
|
239
239
|
|
|
240
|
-
```ts
|
|
240
|
+
```ts fragment
|
|
241
241
|
media.getCustomProperty("alt");
|
|
242
242
|
media.setCustomProperty("alt", "A steel kettle");
|
|
243
243
|
await media.save();
|
|
@@ -247,7 +247,7 @@ await media.save();
|
|
|
247
247
|
|
|
248
248
|
Items carry an `orderColumn`, assigned in insertion order:
|
|
249
249
|
|
|
250
|
-
```ts
|
|
250
|
+
```ts fragment
|
|
251
251
|
await product.setMediaOrder([third.id, first.id], "images");
|
|
252
252
|
```
|
|
253
253
|
|
|
@@ -256,7 +256,7 @@ in only the items a drag-and-drop UI moved does what it looks like.
|
|
|
256
256
|
|
|
257
257
|
## Deleting
|
|
258
258
|
|
|
259
|
-
```ts
|
|
259
|
+
```ts fragment
|
|
260
260
|
await media.delete(); // row + original + every derivative
|
|
261
261
|
await product.clearMediaCollection("images");
|
|
262
262
|
await product.clearAllMedia();
|
|
@@ -271,7 +271,7 @@ that if the images went with it. Those files go on `forceDelete()`.
|
|
|
271
271
|
Media inherits whatever the disk does. Put a collection on a private disk and
|
|
272
272
|
hand out signed, expiring links instead of public URLs:
|
|
273
273
|
|
|
274
|
-
```ts
|
|
274
|
+
```ts fragment
|
|
275
275
|
await media.getTemporaryUrl(300); // the original, for 5 minutes
|
|
276
276
|
await media.getTemporaryUrl(300, "thumb"); // a conversion
|
|
277
277
|
```
|
|
@@ -308,7 +308,7 @@ Pair `Storage.fake()` with `MediaFake`. The first asserts bytes landed, the
|
|
|
308
308
|
second asserts a row points at them — a media row with no file and a file with
|
|
309
309
|
no row are different bugs.
|
|
310
310
|
|
|
311
|
-
```ts
|
|
311
|
+
```ts fragment
|
|
312
312
|
import { Storage } from "zerotal/storage";
|
|
313
313
|
import { MediaFake } from "zerotal/media";
|
|
314
314
|
|
|
@@ -350,7 +350,7 @@ Signatures below are the ones `packages/media/api-surface.md` records, which CI
|
|
|
350
350
|
|
|
351
351
|
`Media` is the mixin — it reads as `Model.using(Media)`, and it declares the static `mediaCollections` field. `MediaItem` is one stored file: a row in the `media` table, and an ordinary model, so every query-builder method is available on it too.
|
|
352
352
|
|
|
353
|
-
```ts
|
|
353
|
+
```ts fragment
|
|
354
354
|
function Media<TBase extends Constructor>(
|
|
355
355
|
Base: TBase,
|
|
356
356
|
): TBase & { mediaCollections: MediaCollections };
|
|
@@ -382,7 +382,7 @@ Columns: `uuid`, `name`, `fileName`, `mimeType`, `size`, `disk`, `conversionsDis
|
|
|
382
382
|
|
|
383
383
|
`addMedia(source)` returns a `MediaAdder`. Every method chains; `toCollection()` is what actually stores the file, and it returns the `MediaItem`.
|
|
384
384
|
|
|
385
|
-
```ts
|
|
385
|
+
```ts fragment
|
|
386
386
|
class MediaAdder {
|
|
387
387
|
usingName(name: string): MediaAdder;
|
|
388
388
|
usingFileName(fileName: string): MediaAdder;
|
|
@@ -395,7 +395,7 @@ class MediaAdder {
|
|
|
395
395
|
|
|
396
396
|
`MediaSource` is what a source may be:
|
|
397
397
|
|
|
398
|
-
```ts
|
|
398
|
+
```ts fragment
|
|
399
399
|
type MediaSource = ArrayBuffer | Blob | UploadedFile | File | Uint8Array;
|
|
400
400
|
```
|
|
401
401
|
|
|
@@ -407,7 +407,7 @@ type MediaSource = ArrayBuffer | Blob | UploadedFile | File | Uint8Array;
|
|
|
407
407
|
|
|
408
408
|
`MediaLibrary` is the facade; `MediaManager` is the class behind it. They are named differently because `Media` is already the mixin, and an app importing both would otherwise have to rename one at every call site.
|
|
409
409
|
|
|
410
|
-
```ts
|
|
410
|
+
```ts fragment
|
|
411
411
|
class MediaManager {
|
|
412
412
|
readonly config: MediaConfigShape;
|
|
413
413
|
readonly driver: ImageDriver;
|
|
@@ -431,7 +431,7 @@ interface CleanReport {
|
|
|
431
431
|
|
|
432
432
|
### Collections and conversions
|
|
433
433
|
|
|
434
|
-
```ts
|
|
434
|
+
```ts fragment
|
|
435
435
|
type MediaCollections = Record<string, CollectionDefinition | (() => CollectionDefinition)>;
|
|
436
436
|
type ConversionMap = Record<string, ConversionDefinition>;
|
|
437
437
|
```
|
|
@@ -477,7 +477,7 @@ interface ResponsiveImageSet {
|
|
|
477
477
|
|
|
478
478
|
`ImageDriver` is the seam between this package and whatever actually manipulates pixels. Two implementations ship — `BunImageDriver` (the default, no dependencies) and `SharpImageDriver` (opt-in, a native module) — and a shared parity suite holds them to the same output dimensions.
|
|
479
479
|
|
|
480
|
-
```ts
|
|
480
|
+
```ts fragment
|
|
481
481
|
interface ImageDriver {
|
|
482
482
|
readonly name: string;
|
|
483
483
|
/** Whether `fit: "cover"` is available. Both shipped drivers report `true`. */
|
|
@@ -519,7 +519,7 @@ Writing your own driver is supported, and the interface may grow only in ways th
|
|
|
519
519
|
|
|
520
520
|
`BunImageDriver` takes pixel ceilings, so a decompression bomb fails as a refusal rather than as memory exhaustion. `SharpImageDriver` takes no arguments — libvips streams tiles rather than materialising the full bitmap, so the ceiling has nothing to protect.
|
|
521
521
|
|
|
522
|
-
```ts
|
|
522
|
+
```ts fragment
|
|
523
523
|
new BunImageDriver(maxPixels?, maxCropPixels?);
|
|
524
524
|
new SharpImageDriver();
|
|
525
525
|
```
|
|
@@ -528,7 +528,7 @@ new SharpImageDriver();
|
|
|
528
528
|
|
|
529
529
|
Three lookup tables are exported for reading — to label a download, or to check a type before offering an upload. They are frozen, because they are shared module state: an app that mutated one would change how conversions behave for every other caller in the process, including ones it does not own.
|
|
530
530
|
|
|
531
|
-
```ts
|
|
531
|
+
```ts fragment
|
|
532
532
|
const FORMAT_MIME: Readonly<Record<ConversionFormat, string>>;
|
|
533
533
|
const FORMAT_EXTENSION: Readonly<Record<ConversionFormat, string>>;
|
|
534
534
|
const CONVERTIBLE_MIME_TYPES: ReadonlySet<string>;
|
|
@@ -541,7 +541,7 @@ function isConvertible(mimeType: string | null | undefined): boolean;
|
|
|
541
541
|
|
|
542
542
|
Supply a `PathGenerator` to change the on-disk layout described under [Where files live](#where-files-live). `DefaultPathGenerator` is the shipped one, and `setPathGenerator` installs yours. It is process-global, so a provider's `register()` is the place for it.
|
|
543
543
|
|
|
544
|
-
```ts
|
|
544
|
+
```ts fragment
|
|
545
545
|
interface PathGenerator {
|
|
546
546
|
forOriginal(media: MediaItem): string;
|
|
547
547
|
forConversions(media: MediaItem): string;
|
|
@@ -555,7 +555,7 @@ class DefaultPathGenerator implements PathGenerator {
|
|
|
555
555
|
function setPathGenerator(generator: PathGenerator): void;
|
|
556
556
|
```
|
|
557
557
|
|
|
558
|
-
```ts
|
|
558
|
+
```ts fragment
|
|
559
559
|
// A provider's register()
|
|
560
560
|
setPathGenerator(new DefaultPathGenerator("uploads"));
|
|
561
561
|
```
|
package/docs/middleware.md
CHANGED
|
@@ -116,7 +116,7 @@ For middleware that must run on _every_ request, see [Global middleware](#global
|
|
|
116
116
|
Pass an array of middleware classes as the fourth argument to any route
|
|
117
117
|
registration method:
|
|
118
118
|
|
|
119
|
-
```ts
|
|
119
|
+
```ts fragment
|
|
120
120
|
// routes/index.ts
|
|
121
121
|
Router.get("/dashboard", DashboardController, "index", [AuthMiddleware]);
|
|
122
122
|
Router.post("/posts", PostController, "store", [AuthMiddleware, ThrottleMiddleware]);
|
|
@@ -124,7 +124,7 @@ Router.post("/posts", PostController, "store", [AuthMiddleware, ThrottleMiddlewa
|
|
|
124
124
|
|
|
125
125
|
### Route groups
|
|
126
126
|
|
|
127
|
-
```ts
|
|
127
|
+
```ts fragment
|
|
128
128
|
// routes/index.ts
|
|
129
129
|
Router.group({ middleware: AuthMiddleware }, () => {
|
|
130
130
|
Router.get("/dashboard", DashboardController, "index");
|
|
@@ -137,7 +137,7 @@ Router.group({ middleware: AuthMiddleware }, () => {
|
|
|
137
137
|
Middleware classes under `app/middleware/` are auto-registered at boot as a **named group under
|
|
138
138
|
their class name** — reference them by string in routes without importing:
|
|
139
139
|
|
|
140
|
-
```ts
|
|
140
|
+
```ts fragment
|
|
141
141
|
// app/middleware/EnsureSubscribed.ts → referenceable as "EnsureSubscribed"
|
|
142
142
|
Router.group({ middleware: ["EnsureSubscribed"] }, () => {
|
|
143
143
|
/* … */
|
|
@@ -151,13 +151,13 @@ global pipeline. See [Conventions](/docs/conventions#middleware-appmiddleware).
|
|
|
151
151
|
|
|
152
152
|
Define a group once, reference it by name everywhere:
|
|
153
153
|
|
|
154
|
-
```ts
|
|
154
|
+
```ts fragment
|
|
155
155
|
// in a ServiceProvider.onRegister()
|
|
156
156
|
Router.middlewareGroup("api", [ThrottleMiddleware, BearerTokenMiddleware]);
|
|
157
157
|
Router.middlewareGroup("web", [SessionMiddleware, CsrfMiddleware]);
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
```ts
|
|
160
|
+
```ts fragment
|
|
161
161
|
// routes/index.ts
|
|
162
162
|
Router.group({ prefix: "/api/v1", middleware: "api" }, () => {
|
|
163
163
|
Router.resource("posts", PostController);
|
|
@@ -173,7 +173,7 @@ Router.group({ middleware: ["web", AuthMiddleware] }, () => {
|
|
|
173
173
|
Drop a `_middleware.ts` file into any directory under your file routes folder.
|
|
174
174
|
It applies to every route file in that directory and all subdirectories:
|
|
175
175
|
|
|
176
|
-
```ts
|
|
176
|
+
```ts fragment
|
|
177
177
|
// app/routes/admin/_middleware.ts
|
|
178
178
|
import { AuthMiddleware } from "@zerotal/auth";
|
|
179
179
|
import { AdminMiddleware } from "../../middleware/AdminMiddleware.ts";
|
|
@@ -188,7 +188,7 @@ you get `root/_middleware → admin/_middleware → route handler` in one pipeli
|
|
|
188
188
|
|
|
189
189
|
Register middleware that runs on every request in `bootstrap/app.ts`:
|
|
190
190
|
|
|
191
|
-
```ts
|
|
191
|
+
```ts fragment
|
|
192
192
|
// bootstrap/app.ts
|
|
193
193
|
export default Application.create({ providers }).use([
|
|
194
194
|
DevtoolsInjectionMiddleware,
|
|
@@ -208,7 +208,7 @@ bakes options into a zero-argument class.
|
|
|
208
208
|
|
|
209
209
|
### CorsMiddleware
|
|
210
210
|
|
|
211
|
-
```ts
|
|
211
|
+
```ts fragment
|
|
212
212
|
// bootstrap/app.ts
|
|
213
213
|
import { CorsMiddleware } from "zerotal";
|
|
214
214
|
|
|
@@ -240,7 +240,7 @@ app.use([CorsMiddleware.with({ origin: (o) => o.endsWith(".mycompany.com") })]);
|
|
|
240
240
|
Rate-limits requests with an in-memory sliding window counter. Returns `429`
|
|
241
241
|
with `Retry-After` and `X-RateLimit-*` headers when the limit is exceeded.
|
|
242
242
|
|
|
243
|
-
```ts
|
|
243
|
+
```ts fragment
|
|
244
244
|
// bootstrap/app.ts (global) and routes/index.ts (per-route)
|
|
245
245
|
import { ThrottleMiddleware } from "zerotal";
|
|
246
246
|
|
|
@@ -295,7 +295,7 @@ RateLimiter.for("upload")
|
|
|
295
295
|
|
|
296
296
|
Apply as route middleware with `RateLimiter.middleware(name)`:
|
|
297
297
|
|
|
298
|
-
```ts
|
|
298
|
+
```ts fragment
|
|
299
299
|
// routes/index.ts
|
|
300
300
|
Router.post("/login", AuthController, "login", [RateLimiter.middleware("login")]);
|
|
301
301
|
|
|
@@ -314,7 +314,7 @@ Router.group({ prefix: "/api", middleware: [RateLimiter.middleware("api")] }, ()
|
|
|
314
314
|
Check or reset a limiter manually — e.g. clear failed login attempts after a
|
|
315
315
|
successful sign-in:
|
|
316
316
|
|
|
317
|
-
```ts
|
|
317
|
+
```ts fragment
|
|
318
318
|
// in a controller action — `ctx` is the HttpContext the action receives
|
|
319
319
|
if (await RateLimiter.tooManyAttempts("login", ctx)) {
|
|
320
320
|
return ctx.json({ message: "Too Many Requests" }, 429);
|
|
@@ -333,7 +333,7 @@ RateLimiter.resetFor("login", ctx); // clear the counter for this actor
|
|
|
333
333
|
Adds `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and a
|
|
334
334
|
basic `Content-Security-Policy` to every response:
|
|
335
335
|
|
|
336
|
-
```ts
|
|
336
|
+
```ts fragment
|
|
337
337
|
// bootstrap/app.ts
|
|
338
338
|
import { SecureHeadersMiddleware } from "zerotal";
|
|
339
339
|
app.use([SecureHeadersMiddleware]);
|
|
@@ -358,7 +358,7 @@ embeddable stays that way.
|
|
|
358
358
|
|
|
359
359
|
Verifies HMAC-SHA256 signatures on incoming webhook requests:
|
|
360
360
|
|
|
361
|
-
```ts
|
|
361
|
+
```ts fragment
|
|
362
362
|
// routes/index.ts
|
|
363
363
|
import { WebhookMiddleware } from "zerotal";
|
|
364
364
|
|
|
@@ -379,7 +379,7 @@ Router.post("/webhooks/stripe", StripeController, "handle", [
|
|
|
379
379
|
Decorate the class with `@inject(...)`, listing its dependency tokens in
|
|
380
380
|
constructor order; the container resolves them and passes them in:
|
|
381
381
|
|
|
382
|
-
```ts
|
|
382
|
+
```ts fragment
|
|
383
383
|
// app/middleware/AuditMiddleware.ts
|
|
384
384
|
import { inject } from "zerotal";
|
|
385
385
|
import type { Pipe, NextFn, HttpContext } from "zerotal";
|
|
@@ -423,7 +423,7 @@ lets a request **through**, and what it does when it **stops** one.
|
|
|
423
423
|
|
|
424
424
|
**Test the stop first**, because it is the reason the middleware exists:
|
|
425
425
|
|
|
426
|
-
```typescript
|
|
426
|
+
```typescript fragment
|
|
427
427
|
// tests/http/middleware.test.ts
|
|
428
428
|
import { test } from "bun:test";
|
|
429
429
|
import { createApp } from "../helpers.ts";
|
|
@@ -441,7 +441,7 @@ test("a guest is turned away from a protected route", async () => {
|
|
|
441
441
|
**Then prove it lets the right request through**, otherwise a middleware that
|
|
442
442
|
rejects everything passes the first test perfectly:
|
|
443
443
|
|
|
444
|
-
```typescript
|
|
444
|
+
```typescript fragment
|
|
445
445
|
// tests/http/middleware.test.ts
|
|
446
446
|
const res = await app.actingAs(user).get("/dashboard");
|
|
447
447
|
|
|
@@ -452,7 +452,7 @@ res.assertOk();
|
|
|
452
452
|
Register a probe route in the `setup` callback — `createTestApp(bootstrap, setup)`
|
|
453
453
|
runs it before the server starts, so the route compiles into the router:
|
|
454
454
|
|
|
455
|
-
```typescript
|
|
455
|
+
```typescript fragment
|
|
456
456
|
// tests/http/middleware.test.ts
|
|
457
457
|
const app = await createApp(() => {
|
|
458
458
|
Router.get("/probe", () => ({ locale: Context.get("locale") })).middleware([LocaleMiddleware]);
|
package/docs/migrations.md
CHANGED
|
@@ -191,7 +191,7 @@ Additive sync leaves columns in place even after you delete them from a model, s
|
|
|
191
191
|
database accumulates orphaned columns over time. To let Zerotal also **drop** columns that
|
|
192
192
|
no model declares anymore, opt in explicitly with the object form:
|
|
193
193
|
|
|
194
|
-
```typescript
|
|
194
|
+
```typescript fragment
|
|
195
195
|
// config/database.ts
|
|
196
196
|
export default DatabaseConfig({
|
|
197
197
|
url: env("DATABASE_URL", "./database/db.sqlite"),
|
|
@@ -222,7 +222,7 @@ connection. Call them from inside `up()` / `down()`.
|
|
|
222
222
|
|
|
223
223
|
### Creating tables
|
|
224
224
|
|
|
225
|
-
```typescript
|
|
225
|
+
```typescript fragment
|
|
226
226
|
// inside a migration's up()
|
|
227
227
|
await Schema.create("users", (table) => {
|
|
228
228
|
table.increments("id"); // INTEGER PRIMARY KEY AUTOINCREMENT
|
|
@@ -244,7 +244,7 @@ await Schema.createIfNotExists("settings", (table) => {
|
|
|
244
244
|
|
|
245
245
|
### Modifying tables
|
|
246
246
|
|
|
247
|
-
```typescript
|
|
247
|
+
```typescript fragment
|
|
248
248
|
// inside a migration's up()
|
|
249
249
|
await Schema.table("users", (table) => {
|
|
250
250
|
table.string("role").default("user"); // ADD COLUMN
|
|
@@ -257,7 +257,7 @@ await Schema.table("users", (table) => {
|
|
|
257
257
|
|
|
258
258
|
### Other Schema methods
|
|
259
259
|
|
|
260
|
-
```typescript
|
|
260
|
+
```typescript fragment
|
|
261
261
|
// inside a migration
|
|
262
262
|
await Schema.drop("users");
|
|
263
263
|
await Schema.dropIfExists("temp_table");
|
|
@@ -310,7 +310,7 @@ column; SQL types reflect the SQLite mappings (other drivers use their native ty
|
|
|
310
310
|
|
|
311
311
|
Chain modifiers on any column:
|
|
312
312
|
|
|
313
|
-
```typescript
|
|
313
|
+
```typescript fragment
|
|
314
314
|
// inside a Blueprint callback
|
|
315
315
|
t.string("bio").nullable();
|
|
316
316
|
t.string("role").default("user");
|
|
@@ -336,7 +336,7 @@ t.dateTime("published_at").nullable().useCurrent();
|
|
|
336
336
|
|
|
337
337
|
## Indexes
|
|
338
338
|
|
|
339
|
-
```typescript
|
|
339
|
+
```typescript fragment
|
|
340
340
|
// inside a migration's up()
|
|
341
341
|
await Schema.create("posts", (table) => {
|
|
342
342
|
table.increments("id");
|
|
@@ -363,7 +363,7 @@ await Schema.create("posts", (table) => {
|
|
|
363
363
|
|
|
364
364
|
## Foreign keys
|
|
365
365
|
|
|
366
|
-
```typescript
|
|
366
|
+
```typescript fragment
|
|
367
367
|
// inside a migration's up()
|
|
368
368
|
await Schema.create("comments", (table) => {
|
|
369
369
|
table.increments("id");
|
|
@@ -379,7 +379,7 @@ await Schema.create("comments", (table) => {
|
|
|
379
379
|
`foreignId()` is shorthand for an unsigned integer column; chain `.constrained()` to
|
|
380
380
|
add the foreign-key constraint, inferring the referenced table from the column name:
|
|
381
381
|
|
|
382
|
-
```typescript
|
|
382
|
+
```typescript fragment
|
|
383
383
|
// inside a Blueprint callback
|
|
384
384
|
t.foreignId("user_id").constrained(); // references users.id
|
|
385
385
|
t.foreignId("post_id").constrained("posts"); // explicit table
|
|
@@ -392,7 +392,7 @@ read more fluently.
|
|
|
392
392
|
|
|
393
393
|
## Soft deletes
|
|
394
394
|
|
|
395
|
-
```typescript
|
|
395
|
+
```typescript fragment
|
|
396
396
|
// inside a Blueprint callback
|
|
397
397
|
t.softDeletes(); // adds nullable deleted_at TEXT column
|
|
398
398
|
t.softDeletes("removed_at"); // custom column name
|
|
@@ -403,7 +403,7 @@ non-null `deleted_at` are excluded from queries automatically.
|
|
|
403
403
|
|
|
404
404
|
## Pivot / join tables
|
|
405
405
|
|
|
406
|
-
```typescript
|
|
406
|
+
```typescript fragment
|
|
407
407
|
// inside a migration's up()
|
|
408
408
|
await Schema.create("post_tags", (table) => {
|
|
409
409
|
table.integer("post_id");
|
|
@@ -418,7 +418,7 @@ await Schema.create("post_tags", (table) => {
|
|
|
418
418
|
|
|
419
419
|
Check schema state before making changes to keep migrations idempotent:
|
|
420
420
|
|
|
421
|
-
```typescript
|
|
421
|
+
```typescript fragment
|
|
422
422
|
// inside a migration
|
|
423
423
|
async up(): Promise<void> {
|
|
424
424
|
if (await Schema.hasColumn("users", "role")) return;
|
|
@@ -456,7 +456,7 @@ free coverage, and it is why a broken migration rarely reaches production.
|
|
|
456
456
|
moment. A migration is only reversible if you have run it backwards at least
|
|
457
457
|
once:
|
|
458
458
|
|
|
459
|
-
```typescript
|
|
459
|
+
```typescript fragment
|
|
460
460
|
// tests/migrations/AddArchivedToPosts.test.ts
|
|
461
461
|
import { test, expect } from "bun:test";
|
|
462
462
|
import { Schema } from "@zerotal/orm";
|
|
@@ -480,7 +480,7 @@ second `up()` should either succeed or fail for a reason you have chosen.
|
|
|
480
480
|
mistakes are unrecoverable. Arrange rows in the old shape, run the migration,
|
|
481
481
|
assert the new shape:
|
|
482
482
|
|
|
483
|
-
```typescript
|
|
483
|
+
```typescript fragment
|
|
484
484
|
// tests/migrations/BackfillSlugs.test.ts
|
|
485
485
|
test("backfills a slug for every existing post", async () => {
|
|
486
486
|
await DB.table("posts").insert({ title: "Hello World", slug: null });
|