@zerotal/arch 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -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.