@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Broadcasting References
|
|
3
|
+
description: The Broadcast facade, channel APIs, errors, and commands.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# References
|
|
7
|
+
|
|
8
|
+
## Configuration
|
|
9
|
+
|
|
10
|
+
| Key | Values | Default |
|
|
11
|
+
| -------- | ---------------------------------------------------- | --------- |
|
|
12
|
+
| `driver` | `null`, `ws`, `redis`, `pusher` | `null` |
|
|
13
|
+
| `path` | WebSocket endpoint path | `/app/ws` |
|
|
14
|
+
| `redis` | `{ url }` — required by the `redis` driver | — |
|
|
15
|
+
| `pusher` | Pusher credentials — required by the `pusher` driver | — |
|
|
16
|
+
|
|
17
|
+
The `null` driver discards everything broadcast to it. That is the right default for
|
|
18
|
+
tests and for a development machine with no broker running, and it is why a missing
|
|
19
|
+
configuration shows up as silence rather than as an error.
|
|
20
|
+
|
|
21
|
+
## The BroadcastEvent interface
|
|
22
|
+
|
|
23
|
+
Any object implementing this can be handed to `Broadcast.send()`:
|
|
24
|
+
|
|
25
|
+
| Member | Required | Defaults to |
|
|
26
|
+
| ----------------- | -------- | ---------------------------- |
|
|
27
|
+
| `broadcastOn()` | Yes | — |
|
|
28
|
+
| `broadcastAs()` | No | The class's constructor name |
|
|
29
|
+
| `broadcastWith()` | No | `{}` |
|
|
30
|
+
|
|
31
|
+
Because `broadcastAs()` falls back to the constructor name, renaming an event class
|
|
32
|
+
silently renames the event clients listen for. Implement it explicitly on anything a
|
|
33
|
+
browser subscribes to and the wire name stops depending on a refactor.
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
`@zerotal/broadcasting` ships two channel commands:
|
|
38
|
+
|
|
39
|
+
| Command | What it does |
|
|
40
|
+
| ---------------------------------- | -------------------------------------------------------- |
|
|
41
|
+
| `bun zt channel:list` | List registered broadcast channel authorization rules |
|
|
42
|
+
| `bun zt make:channel OrderChannel` | Add a channel authorization rule to `routes/channels.ts` |
|
|
43
|
+
|
|
44
|
+
## Broadcast facade
|
|
45
|
+
|
|
46
|
+
| Method | Signature | Description |
|
|
47
|
+
| ------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
48
|
+
| `Broadcast.send(event, opts?)` | `(event: BroadcastEvent, opts?: { exceptSocketId?: string }) => void` | Broadcast an event to every channel from its `broadcastOn()`. |
|
|
49
|
+
| `Broadcast.to(channel, name, data?, opts?)` | `(channel: string, eventName: string, data?: unknown, opts?) => void` | Push a raw event to one channel without an event class. |
|
|
50
|
+
| `Broadcast.on(channel)` | `(channel: string) => AnonymousBroadcast` | Begin an anonymous public broadcast (fluent `.as().with().send()`). |
|
|
51
|
+
| `Broadcast.private(channel)` | `(channel: string) => AnonymousBroadcast` | Anonymous broadcast on a private channel. |
|
|
52
|
+
| `Broadcast.presence(channel)` | `(channel: string) => AnonymousBroadcast` | Anonymous broadcast on a presence channel. |
|
|
53
|
+
| `Broadcast.channel(pattern, callback)` | `(pattern: string, callback: ChannelCallback) => void` | Register a channel authorization rule (call in `routes/channels.ts`). |
|
|
54
|
+
| `Broadcast.channels()` | `() => { pattern: string; paramNames: string[] }[]` | List registered channel patterns. |
|
|
55
|
+
| `Broadcast.getMembers(channel)` | `(channel: string) => PresenceMember[]` | Members of a presence channel (real driver only). |
|
|
56
|
+
| `Broadcast.fake()` | `() => BroadcastFake` | Swap in an in-memory recorder for tests. |
|
|
57
|
+
| `Broadcast.resetFake()` | `() => void` | Restore container-backed resolution. |
|
|
58
|
+
|
|
59
|
+
`getMembers()` reads state the driver holds, so it returns an empty list under the
|
|
60
|
+
`null` driver and under `fake()`. Assert presence membership against a real driver,
|
|
61
|
+
or assert on the broadcasts themselves instead.
|
|
62
|
+
|
|
63
|
+
## Errors
|
|
64
|
+
|
|
65
|
+
| Error | Thrown when |
|
|
66
|
+
| ------------------------------------- | ------------------------------------------------------------------------ |
|
|
67
|
+
| `BroadcastProviderNotRegisteredError` | The `Broadcast` facade is used before `BroadcastProvider` is registered. |
|
|
68
|
+
| `MissingChannelParameterError` | A `[param]` placeholder is interpolated without its value. |
|
|
69
|
+
|
|
70
|
+
Both extend `BroadcastError`, which extends `ZerotalError`, so one `catch` on
|
|
71
|
+
`BroadcastError` covers the pair and a `ZerotalError` handler catches them alongside
|
|
72
|
+
the rest of the framework's errors.
|
|
73
|
+
|
|
74
|
+
## Broadcast notifications
|
|
75
|
+
|
|
76
|
+
Notifications can be delivered over a broadcast channel in real time — add `'broadcast'` to a
|
|
77
|
+
notification's `channels()` and implement `toBroadcast()`. See
|
|
78
|
+
[Notifications → Broadcasting](/docs/notifications#broadcast).
|
|
79
|
+
|
|
80
|
+
## Next steps
|
|
81
|
+
|
|
82
|
+
- [Broadcasting overview](/docs/broadcasting) — the guide's front page and the rest of the sections.
|
|
83
|
+
- [Events](/docs/broadcasting/events) — writing the events this facade sends.
|
|
84
|
+
- [Testing](/docs/broadcasting/testing) — the fake and its assertions.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing Broadcasting
|
|
3
|
+
description: Fake the broadcaster and assert on what would have been sent.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
|
|
8
|
+
A broadcast is a side effect that leaves your process, which makes it awkward to
|
|
9
|
+
observe and slow to exercise against a real driver. `Broadcast.fake()` swaps the
|
|
10
|
+
live manager for an in-memory recorder, so the code under test broadcasts exactly
|
|
11
|
+
as it normally would and the test inspects what came out — no Redis, no Pusher
|
|
12
|
+
credentials, and no waiting on a socket.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// in a test
|
|
16
|
+
import { Broadcast } from "@zerotal/broadcasting";
|
|
17
|
+
|
|
18
|
+
const fake = Broadcast.fake();
|
|
19
|
+
|
|
20
|
+
// Run code that broadcasts
|
|
21
|
+
await PostController.publish({ http: ctx });
|
|
22
|
+
|
|
23
|
+
// Assert by event name (and optionally channel + partial payload)
|
|
24
|
+
fake.assertBroadcast("PostPublished", "posts");
|
|
25
|
+
fake.assertBroadcast("PostPublished", "posts", { id: post.id });
|
|
26
|
+
|
|
27
|
+
Broadcast.resetFake();
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`fake()` stays installed until you remove it, so restore the real manager in
|
|
31
|
+
`afterEach` — otherwise the first test to fake broadcasting silently mutes every
|
|
32
|
+
test that follows it.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
afterEach(() => Broadcast.resetFake());
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Available assertions
|
|
39
|
+
|
|
40
|
+
| Method | Passes when … |
|
|
41
|
+
| ----------------------------------------- | ------------------------------------------------------ |
|
|
42
|
+
| `assertBroadcast(event, channel?, data?)` | A matching broadcast was recorded. |
|
|
43
|
+
| `assertNotBroadcast(event, channel?)` | No matching broadcast was recorded. |
|
|
44
|
+
| `assertNothingBroadcast()` | Nothing at all was broadcast. |
|
|
45
|
+
| `assertBroadcastCount(n)` | Exactly `n` broadcasts were recorded. |
|
|
46
|
+
| `recorded()` | Returns the raw `{ channel, event, data }[]` list. |
|
|
47
|
+
| `reset()` | Clears recorded broadcasts (keeps the fake installed). |
|
|
48
|
+
|
|
49
|
+
The `data` argument to `assertBroadcast` is a **partial** match — only the keys you
|
|
50
|
+
pass are compared, so you can assert on a single field without spelling out the
|
|
51
|
+
whole payload.
|
|
52
|
+
|
|
53
|
+
Arguments narrow the match rather than replace it. `assertBroadcast("PostPublished")`
|
|
54
|
+
accepts the event on any channel with any payload; adding a channel demands that
|
|
55
|
+
channel too; adding data demands those keys as well. Start broad and tighten only
|
|
56
|
+
to the part the test is actually about, so unrelated payload changes do not break it.
|
|
57
|
+
|
|
58
|
+
When an assertion fails, the error lists everything that _was_ recorded — usually
|
|
59
|
+
enough to see that the event fired on a different channel, or under a different
|
|
60
|
+
name than `broadcastAs()` produces.
|
|
61
|
+
|
|
62
|
+
## reset() or resetFake()?
|
|
63
|
+
|
|
64
|
+
Two similarly named calls do different jobs, and reaching for the wrong one is the
|
|
65
|
+
usual cause of a test that passes alone and fails in a suite:
|
|
66
|
+
|
|
67
|
+
- **`fake.reset()`** empties the recorded list and leaves the fake installed. Use it
|
|
68
|
+
between phases of one test — arrange, clear, then assert only on what the action
|
|
69
|
+
under test produced.
|
|
70
|
+
- **`Broadcast.resetFake()`** removes the fake entirely and restores container-backed
|
|
71
|
+
resolution. Use it in `afterEach`.
|
|
72
|
+
|
|
73
|
+
## Asserting nothing was sent
|
|
74
|
+
|
|
75
|
+
Proving a broadcast did _not_ happen is often the more valuable test, because a
|
|
76
|
+
stray broadcast reaches real users. Both negative assertions are worth reaching
|
|
77
|
+
for:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
it("does not broadcast when validation fails", async () => {
|
|
81
|
+
const fake = Broadcast.fake();
|
|
82
|
+
|
|
83
|
+
await PostController.publish({ http: invalidCtx });
|
|
84
|
+
|
|
85
|
+
fake.assertNothingBroadcast();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("broadcasts the update but not a deletion", async () => {
|
|
89
|
+
const fake = Broadcast.fake();
|
|
90
|
+
|
|
91
|
+
await post.update({ title: "Edited" });
|
|
92
|
+
|
|
93
|
+
fake.assertBroadcast("PostUpdated");
|
|
94
|
+
fake.assertNotBroadcast("PostDeleted");
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Model broadcasts
|
|
99
|
+
|
|
100
|
+
Models that broadcast their own changes go through the same manager, so they need
|
|
101
|
+
no special handling — save the model and assert. The event name is whatever
|
|
102
|
+
`broadcastAs()` returns and the payload whatever `broadcastWith()` builds, so
|
|
103
|
+
asserting on both is what pins that mapping down:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const fake = Broadcast.fake();
|
|
107
|
+
|
|
108
|
+
await Post.create({ title: "Hello" });
|
|
109
|
+
|
|
110
|
+
fake.assertBroadcast("PostCreated", "posts", { title: "Hello" });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Broadcasts that skip the sender
|
|
114
|
+
|
|
115
|
+
`broadcast(event).toOthers()` excludes the connection that triggered it by passing
|
|
116
|
+
its socket id along. The fake records the broadcast either way, so a test that only
|
|
117
|
+
asserts the event fired will pass whether or not `toOthers()` was used. To pin that
|
|
118
|
+
behaviour down, read the recorded entries directly:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const [entry] = fake.recorded();
|
|
122
|
+
expect(entry.event).toBe("PostUpdated");
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`recorded()` is the escape hatch generally — when an assertion helper does not
|
|
126
|
+
express the question, the raw `{ channel, event, data }` list will.
|
|
127
|
+
|
|
128
|
+
## Next steps
|
|
129
|
+
|
|
130
|
+
- [Broadcasting overview](/docs/broadcasting) — the guide's front page and the rest of the sections.
|
|
131
|
+
- [Events](/docs/broadcasting/events) — writing the events these tests assert on.
|
|
132
|
+
- [Channels](/docs/broadcasting/channels) — the channels they are sent to.
|
package/docs/cache.md
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Cache
|
|
3
|
+
description: Store expensive query results, computed values, and API responses so repeat reads skip the work.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cache
|
|
7
|
+
|
|
8
|
+
A unified caching layer with SQLite, Redis, and in-memory drivers. Wrap slow database queries, computed values, and external API responses in a cache so the second read is cheap, and tag related entries to invalidate them together.
|
|
9
|
+
|
|
10
|
+
## Getting Started
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# in your project root
|
|
14
|
+
bun add @zerotal/cache
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Register the provider
|
|
18
|
+
|
|
19
|
+
Add `CacheProvider` to the providers array in `bootstrap/providers.ts`:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// bootstrap/providers.ts
|
|
23
|
+
import { CacheProvider } from "@zerotal/cache";
|
|
24
|
+
|
|
25
|
+
export default [
|
|
26
|
+
// …other providers
|
|
27
|
+
CacheProvider,
|
|
28
|
+
];
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Registering the provider switches on the following:
|
|
32
|
+
|
|
33
|
+
- `onRegister` — binds `CacheManager` as a lazy singleton under the `cache` key, building the configured driver (sqlite, redis, or memory).
|
|
34
|
+
- `onBooted` — pre-resolves the `cache` singleton so the `Cache` facade works after boot, and registers the `cache:clear` console command.
|
|
35
|
+
|
|
36
|
+
> **Note** — The provider is active in the `web`, `console`, `test`, and `repl` environments.
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
Create `config/cache.ts` with the `CacheConfig()` helper so every field stays type-checked while you only override what you need:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// config/cache.ts
|
|
44
|
+
import { CacheConfig } from "@zerotal/cache";
|
|
45
|
+
import { env } from "zerotal";
|
|
46
|
+
|
|
47
|
+
export default CacheConfig({
|
|
48
|
+
driver: env("CACHE_DRIVER", "sqlite"), // 'sqlite' | 'redis' | 'memory'
|
|
49
|
+
prefix: env("CACHE_PREFIX", "zerotal:"), // prepended to every key
|
|
50
|
+
ttl: env("CACHE_TTL", 3600), // default TTL in seconds (1 hour)
|
|
51
|
+
|
|
52
|
+
sqlite: {
|
|
53
|
+
path: env("CACHE_SQLITE_PATH", ":memory:"), // ':memory:' or a file path
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Field | Required | Default | Description |
|
|
59
|
+
| ------------- | -------- | ------------ | ----------------------------------------------------------- |
|
|
60
|
+
| `driver` | no | `"sqlite"` | Which backend to use: `"sqlite"`, `"redis"`, or `"memory"`. |
|
|
61
|
+
| `prefix` | no | `"zerotal:"` | Key prefix prepended to every cache key. |
|
|
62
|
+
| `ttl` | no | `3600` | Default TTL in seconds when a call omits one. |
|
|
63
|
+
| `sqlite.path` | no | `":memory:"` | SQLite file path, or `":memory:"` for an in-process store. |
|
|
64
|
+
|
|
65
|
+
> **Note** — The Redis driver connects through Bun's built-in `redis` client, which reads the `REDIS_URL` environment variable. There is no `redis` block in the cache config — set `REDIS_URL` in your `.env` instead.
|
|
66
|
+
|
|
67
|
+
## Basic operations
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// in a controller or service
|
|
71
|
+
import { Cache } from "@zerotal/cache";
|
|
72
|
+
|
|
73
|
+
// Write with TTL (seconds)
|
|
74
|
+
await Cache.set("user:1", { id: 1, name: "Alice" }, 300); // expires in 5 minutes
|
|
75
|
+
|
|
76
|
+
// Write with no expiry
|
|
77
|
+
await Cache.forever("settings:global", settings);
|
|
78
|
+
|
|
79
|
+
// Read — returns null on miss
|
|
80
|
+
const user = await Cache.get<{ id: number; name: string }>("user:1");
|
|
81
|
+
|
|
82
|
+
// Check existence
|
|
83
|
+
const exists = await Cache.has("user:1"); // boolean
|
|
84
|
+
|
|
85
|
+
// Delete one key
|
|
86
|
+
await Cache.forget("user:1");
|
|
87
|
+
|
|
88
|
+
// Wipe all keys for the configured prefix
|
|
89
|
+
await Cache.flush();
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## remember — the primary workhorse
|
|
93
|
+
|
|
94
|
+
`remember()` checks the cache and, on a miss, calls the factory, stores the result, and returns it. Under high concurrency, multiple callers for the same key coalesce — the factory runs exactly once:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
function remember<T>(key: string, ttl: number, fn: () => Promise<T> | T): Promise<T>;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
> **Tip** — Reach for `remember()` instead of a manual `get`/`set` pair. It avoids the cache stampede where many requests miss at once and all hit the database.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// in a controller
|
|
104
|
+
import { Cache } from "@zerotal/cache";
|
|
105
|
+
import { Post } from "../app/models/Post.ts";
|
|
106
|
+
|
|
107
|
+
// Cache a slow database query for 5 minutes
|
|
108
|
+
const posts = await Cache.remember("posts:page:1", 300, async () => {
|
|
109
|
+
return Post.query().where("status", "published").orderBy("published_at", "desc").paginate(10, 1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Cache an external API response for 1 hour
|
|
113
|
+
const rates = await Cache.remember("exchange-rates", 3600, async () => {
|
|
114
|
+
const res = await fetch("https://api.exchangerate-api.com/v4/latest/USD");
|
|
115
|
+
return res.json();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Per-user computed stats cached for 60 seconds
|
|
119
|
+
const stats = await Cache.remember(`user:${userId}:stats`, 60, async () => {
|
|
120
|
+
const [posts, comments, likes] = await Promise.all([
|
|
121
|
+
Post.query().where("user_id", userId).count(),
|
|
122
|
+
Comment.query().where("user_id", userId).count(),
|
|
123
|
+
Like.query().where("user_id", userId).count(),
|
|
124
|
+
]);
|
|
125
|
+
return { posts, comments, likes };
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Tags
|
|
130
|
+
|
|
131
|
+
Group related keys under named tags so you can invalidate them together. `Cache.tags()` returns a `TaggedCache` whose keys all share a tag prefix — useful when several cache entries depend on the same underlying data:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// in a controller
|
|
135
|
+
import { Cache } from "@zerotal/cache";
|
|
136
|
+
|
|
137
|
+
// Store with tags
|
|
138
|
+
await Cache.tags(["posts", "public"]).set("posts:page:1", data, 300);
|
|
139
|
+
await Cache.tags(["posts", "public"]).set("posts:page:2", data, 300);
|
|
140
|
+
await Cache.tags(["posts", "featured"]).set("posts:featured", featured, 300);
|
|
141
|
+
await Cache.tags([`user:${userId}`]).set(`user:${userId}:profile`, profile, 600);
|
|
142
|
+
|
|
143
|
+
// remember() with tags
|
|
144
|
+
const featured = await Cache.tags(["posts", "featured"]).remember("posts:featured", 300, () =>
|
|
145
|
+
Post.query().where("featured", true).get(),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Bust all keys under the 'posts' tag group
|
|
149
|
+
await Cache.tags(["posts"]).flush();
|
|
150
|
+
|
|
151
|
+
// Bust only a specific user's cache
|
|
152
|
+
await Cache.tags([`user:${userId}`]).flush();
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
> **Note** — `tags(["posts"]).flush()` removes every key whose tag prefix starts with `posts:`. Because the tag prefix is positional, store and flush with the tags in the same order.
|
|
156
|
+
|
|
157
|
+
### Tag-based invalidation in model hooks
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// app/models/Post.ts — inside a lifecycle hook or observer
|
|
161
|
+
async afterCreate(post: Post) {
|
|
162
|
+
await Cache.tags(["posts"]).flush();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async afterUpdate(post: Post) {
|
|
166
|
+
await Cache.tags(["posts", `post:${post.id}`]).flush();
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Batch operations
|
|
171
|
+
|
|
172
|
+
There is no multi-get primitive — batch with `Promise.all` to cut round-trips to the backend:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// in a controller
|
|
176
|
+
import { Cache } from "@zerotal/cache";
|
|
177
|
+
|
|
178
|
+
// Read several keys at once
|
|
179
|
+
const [user, settings, flags] = await Promise.all([
|
|
180
|
+
Cache.get("user:1"),
|
|
181
|
+
Cache.get("settings"),
|
|
182
|
+
Cache.get("feature-flags"),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
// Write several keys at once
|
|
186
|
+
await Promise.all([Cache.set("user:1", userData, 300), Cache.set("user:1:perms", perms, 300)]);
|
|
187
|
+
|
|
188
|
+
// Forget several keys at once
|
|
189
|
+
await Promise.all([
|
|
190
|
+
Cache.forget("user:1"),
|
|
191
|
+
Cache.forget("user:1:perms"),
|
|
192
|
+
Cache.forget("user:1:stats"),
|
|
193
|
+
]);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Drivers
|
|
197
|
+
|
|
198
|
+
| Driver | Notes |
|
|
199
|
+
| ---------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
200
|
+
| `"sqlite"` | Stored in a SQLite database (`:memory:` by default, or a file). Persistent when given a path. Default driver. |
|
|
201
|
+
| `"redis"` | Stored in Redis via Bun's `redis` client. Shared across instances. Use for horizontally-scaled apps. Needs `REDIS_URL`. |
|
|
202
|
+
| `"memory"` | In-process `Map`. Lost on restart. Fast; ideal for tests or per-request caching. |
|
|
203
|
+
|
|
204
|
+
### Which driver should I use?
|
|
205
|
+
|
|
206
|
+
- **`sqlite`** (default) — single-server deployments. Point `sqlite.path` at a file to survive restarts, or leave it `:memory:` for a fast process-local cache.
|
|
207
|
+
- **`redis`** — multiple app instances that must share a cache. Set `REDIS_URL`.
|
|
208
|
+
- **`memory`** — tests and short-lived per-request caches where persistence and sharing don't matter.
|
|
209
|
+
|
|
210
|
+
### Per-use driver override
|
|
211
|
+
|
|
212
|
+
The `Cache` facade always uses the configured driver. To use a different driver for one use case, construct a `CacheManager` directly:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// in a service
|
|
216
|
+
import { CacheManager, MemoryDriver } from "@zerotal/cache";
|
|
217
|
+
|
|
218
|
+
// A short-lived in-process cache with its own prefix and default TTL
|
|
219
|
+
const local = new CacheManager(new MemoryDriver(), "req:", 30);
|
|
220
|
+
await local.set("computed-total", total);
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
new CacheManager(driver: CacheDriver, prefix?: string, defaultTtl?: number)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Idempotency middleware
|
|
228
|
+
|
|
229
|
+
`IdempotencyMiddleware` prevents double-execution of mutating requests. Clients send an `Idempotency-Key` header; the first request runs normally and the response is cached. Any retry with the same key receives the stored response without re-running the handler.
|
|
230
|
+
|
|
231
|
+
`with()` needs a `CacheManager` instance — resolve the framework's bound manager from the container with `app.container.makeSync("cache")`:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
// routes/api.ts
|
|
235
|
+
import { IdempotencyMiddleware, CacheManager } from "@zerotal/cache";
|
|
236
|
+
|
|
237
|
+
const cache = app.container.makeSync("cache") as CacheManager;
|
|
238
|
+
|
|
239
|
+
// Per-route (recommended — protects only mutation endpoints)
|
|
240
|
+
Router.post("/api/orders", [OrderController, "store"], {
|
|
241
|
+
middleware: [IdempotencyMiddleware.with({ cache })],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Global — applied to all matching methods
|
|
245
|
+
app.use([
|
|
246
|
+
IdempotencyMiddleware.with({
|
|
247
|
+
cache,
|
|
248
|
+
ttl: 48 * 3600, // store replays for 48 h (default: 24 h)
|
|
249
|
+
validateBody: true, // 422 if same key reused with a different body
|
|
250
|
+
}),
|
|
251
|
+
]);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Client side:
|
|
255
|
+
|
|
256
|
+
```http
|
|
257
|
+
POST /api/orders HTTP/1.1
|
|
258
|
+
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
|
259
|
+
Content-Type: application/json
|
|
260
|
+
|
|
261
|
+
{ "productId": 42, "qty": 1 }
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
A replayed response includes `Idempotency-Replay: true` so clients can distinguish a live response from a cached one.
|
|
265
|
+
|
|
266
|
+
### Options
|
|
267
|
+
|
|
268
|
+
| Option | Default | Description |
|
|
269
|
+
| -------------- | --------------------------------- | -------------------------------------------------------------------------- |
|
|
270
|
+
| `cache` | required | `CacheManager` instance that stores idempotent responses. |
|
|
271
|
+
| `ttl` | `86400` | Seconds to retain the stored response. After expiry the key can be reused. |
|
|
272
|
+
| `methods` | `["POST","PUT","PATCH","DELETE"]` | Methods subject to idempotency checks. |
|
|
273
|
+
| `header` | `"Idempotency-Key"` | Header name the client sends. |
|
|
274
|
+
| `validateBody` | `false` | Return 422 if the same key arrives with a different request body. |
|
|
275
|
+
|
|
276
|
+
### Behaviour details
|
|
277
|
+
|
|
278
|
+
- **5xx responses are never cached** — transient errors don't permanently block retries.
|
|
279
|
+
- **Concurrent in-process requests** with the same key coalesce — the second caller waits for the first to finish and receives the same response.
|
|
280
|
+
- **Cross-process requests** are serialized by a distributed lock (the [lock primitive](/docs/lock)) when a lock driver is configured, so only one node executes the handler per key. This is on by default; pass `useLock: false` to disable. Without a lock driver it degrades to shared-backend replay (Redis or SQLite).
|
|
281
|
+
|
|
282
|
+
## Cache warming
|
|
283
|
+
|
|
284
|
+
Pre-populate the cache at boot so the first real request is always fast. Call from a provider's `onStarted()`, which runs after the application has finished booting:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
// bootstrap/providers/AppServiceProvider.ts
|
|
288
|
+
import { ServiceProvider } from "zerotal";
|
|
289
|
+
import { Cache } from "@zerotal/cache";
|
|
290
|
+
import { Post } from "../../app/models/Post.ts";
|
|
291
|
+
|
|
292
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
293
|
+
override async onStarted() {
|
|
294
|
+
// Warm frequently-read data
|
|
295
|
+
await Cache.remember("posts:featured", 300, () =>
|
|
296
|
+
Post.query().where("featured", true).limit(6).get(),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Testing
|
|
303
|
+
|
|
304
|
+
Use the `memory` driver in tests for speed and isolation — it never persists between runs. The simplest path is to set `CACHE_DRIVER=memory` in `.env.test` and let the provider build a memory cache automatically.
|
|
305
|
+
|
|
306
|
+
To swap the cache for a single suite, rebind the `cache` singleton on the container before resolving the facade, then flush between tests to avoid bleed:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
// in test setup
|
|
310
|
+
import { Application } from "zerotal";
|
|
311
|
+
import { Cache, CacheManager, MemoryDriver } from "@zerotal/cache";
|
|
312
|
+
|
|
313
|
+
const app = currentApp();
|
|
314
|
+
app.container.singleton("cache", () => new CacheManager(new MemoryDriver(), "test:", 3600));
|
|
315
|
+
|
|
316
|
+
afterEach(async () => {
|
|
317
|
+
await Cache.flush();
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
> **Tip** — Constructing `MemoryDriver` directly in a unit test (`new CacheManager(new MemoryDriver())`) lets you test cache-dependent code without booting the whole application.
|
|
322
|
+
|
|
323
|
+
## References
|
|
324
|
+
|
|
325
|
+
`Cache` is a facade over the bound `CacheManager`. Every method below is called as `Cache.xxx(...)` or on a manager you constructed yourself.
|
|
326
|
+
|
|
327
|
+
| Method | Signature | Description |
|
|
328
|
+
| ---------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
329
|
+
| `get` | `get<T>(key: string): Promise<T \| null>` | Read a value; `null` on miss. |
|
|
330
|
+
| `set` | `set(key: string, value: unknown, ttl?: number): Promise<void>` | Store a value; `ttl` in seconds, falls back to the configured default. |
|
|
331
|
+
| `forever` | `forever(key: string, value: unknown): Promise<void>` | Store a value with no expiry. |
|
|
332
|
+
| `has` | `has(key: string): Promise<boolean>` | Whether a (non-expired) key exists. |
|
|
333
|
+
| `forget` | `forget(key: string): Promise<void>` | Remove a single key. |
|
|
334
|
+
| `flush` | `flush(): Promise<void>` | Remove every key under the current prefix. |
|
|
335
|
+
| `remember` | `remember<T>(key, ttl: number, fn: () => Promise<T> \| T): Promise<T>` | Return the cached value, or compute, store, and return it. |
|
|
336
|
+
| `tags` | `tags(tagNames: string[]): TaggedCache` | Scope subsequent operations to a tag group. |
|
|
337
|
+
| `dispose` | `dispose(): void` | Release driver resources (timers, connections). |
|
|
338
|
+
|
|
339
|
+
### Commands
|
|
340
|
+
|
|
341
|
+
`@zerotal/cache` ships one command:
|
|
342
|
+
|
|
343
|
+
| Command | What it does |
|
|
344
|
+
| -------------------- | -------------------------------------------------------- |
|
|
345
|
+
| `bun zt cache:clear` | Clear all cached values (`--driver` to target one store) |
|
|
346
|
+
|
|
347
|
+
### Errors
|
|
348
|
+
|
|
349
|
+
Cache errors extend `CacheError`, which extends the framework's `ZerotalError`.
|
|
350
|
+
|
|
351
|
+
| Error | Code | Raised when |
|
|
352
|
+
| --------------------------- | ------------------------- | ------------------------------------------------------------ |
|
|
353
|
+
| `CacheError` | `E_CACHE` | Base class — catch this to handle any cache failure. |
|
|
354
|
+
| `CacheSerializationError` | `E_CACHE_SERIALIZATION` | A value cannot be serialised for storage. |
|
|
355
|
+
| `CacheDeserializationError` | `E_CACHE_DESERIALIZATION` | A stored value cannot be read back — usually a shape change. |
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// in a service
|
|
359
|
+
import { CacheDeserializationError } from "@zerotal/cache";
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
return await Cache.get<Report>("report:q3");
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// A deploy changed the shape — drop the entry and rebuild rather than 500.
|
|
365
|
+
if (error instanceof CacheDeserializationError) {
|
|
366
|
+
await Cache.forget("report:q3");
|
|
367
|
+
return buildReport();
|
|
368
|
+
}
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
`CacheDeserializationError` is the one that shows up in production: a cached
|
|
374
|
+
value written by the previous release no longer matches the type the new code
|
|
375
|
+
expects. Treat it as a miss, not a failure — which is what the example above
|
|
376
|
+
does.
|
|
377
|
+
|
|
378
|
+
> **Warning** — A value containing a circular reference or a `BigInt` raises
|
|
379
|
+
> `CacheSerializationError` at write time. It is a bug in the caller rather than
|
|
380
|
+
> a cache fault, so let it surface in development instead of swallowing it.
|
|
381
|
+
|
|
382
|
+
## Next steps
|
|
383
|
+
|
|
384
|
+
- [Query Builder](/docs/query-builder) — the queries you'll most often wrap in `remember()`.
|
|
385
|
+
- [Lock](/docs/lock) — strict cross-process locking to pair with idempotency.
|
|
386
|
+
- [Rate Limiting](/docs/rate-limiting) — request throttling built on the cache backend.
|
|
387
|
+
- [Storage](/docs/storage) — persist larger artifacts that don't belong in the cache.
|