@shaferllc/keel 0.66.0 → 0.68.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 (132) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +2 -2
  6. package/dist/core/cache.d.ts +82 -5
  7. package/dist/core/cache.js +181 -23
  8. package/dist/core/cli/stubs.d.ts +12 -0
  9. package/dist/core/cli/stubs.js +120 -0
  10. package/dist/core/events.d.ts +129 -5
  11. package/dist/core/events.js +165 -7
  12. package/dist/core/health.d.ts +141 -0
  13. package/dist/core/health.js +226 -0
  14. package/dist/core/helpers.d.ts +9 -3
  15. package/dist/core/helpers.js +11 -3
  16. package/dist/core/index.d.ts +15 -10
  17. package/dist/core/index.js +7 -4
  18. package/dist/core/lock.d.ts +139 -0
  19. package/dist/core/lock.js +215 -0
  20. package/dist/core/logger.d.ts +82 -4
  21. package/dist/core/logger.js +141 -23
  22. package/dist/core/mail.d.ts +128 -7
  23. package/dist/core/mail.js +264 -16
  24. package/dist/core/queue.d.ts +134 -9
  25. package/dist/core/queue.js +304 -14
  26. package/dist/core/storage.d.ts +159 -6
  27. package/dist/core/storage.js +287 -7
  28. package/dist/mcp/server.d.ts +19 -0
  29. package/dist/mcp/server.js +355 -0
  30. package/docs/ai-manifest.json +2472 -0
  31. package/docs/ai.md +128 -0
  32. package/docs/architecture.md +331 -0
  33. package/docs/authentication.md +453 -0
  34. package/docs/authorization.md +167 -0
  35. package/docs/broadcasting.md +137 -0
  36. package/docs/broker.md +500 -0
  37. package/docs/cache.md +558 -0
  38. package/docs/configuration.md +311 -0
  39. package/docs/console.md +356 -0
  40. package/docs/container.md +467 -0
  41. package/docs/controllers.md +265 -0
  42. package/docs/cors.md +51 -0
  43. package/docs/database.md +530 -0
  44. package/docs/debugging.md +129 -0
  45. package/docs/decorators.md +127 -0
  46. package/docs/errors.md +395 -0
  47. package/docs/events.md +496 -0
  48. package/docs/examples/architecture-app.ts +27 -0
  49. package/docs/examples/authentication.ts +61 -0
  50. package/docs/examples/authorization.ts +79 -0
  51. package/docs/examples/broadcasting.ts +60 -0
  52. package/docs/examples/broker-cache-validate.ts +34 -0
  53. package/docs/examples/broker-fault-tolerance.ts +29 -0
  54. package/docs/examples/broker-middleware.ts +27 -0
  55. package/docs/examples/broker.ts +203 -0
  56. package/docs/examples/cache.ts +222 -0
  57. package/docs/examples/configuration.ts +81 -0
  58. package/docs/examples/container.ts +134 -0
  59. package/docs/examples/controllers.ts +86 -0
  60. package/docs/examples/database.ts +118 -0
  61. package/docs/examples/debugging.ts +41 -0
  62. package/docs/examples/decorators.ts +40 -0
  63. package/docs/examples/errors.ts +121 -0
  64. package/docs/examples/events.ts +204 -0
  65. package/docs/examples/factories.ts +84 -0
  66. package/docs/examples/hashing.ts +71 -0
  67. package/docs/examples/health.ts +94 -0
  68. package/docs/examples/helpers.ts +171 -0
  69. package/docs/examples/hooks.ts +54 -0
  70. package/docs/examples/inertia.ts +81 -0
  71. package/docs/examples/locks.ts +120 -0
  72. package/docs/examples/logger.ts +92 -0
  73. package/docs/examples/mail.ts +160 -0
  74. package/docs/examples/middleware.ts +119 -0
  75. package/docs/examples/migrations.ts +126 -0
  76. package/docs/examples/models.ts +239 -0
  77. package/docs/examples/notification.ts +124 -0
  78. package/docs/examples/providers.ts +123 -0
  79. package/docs/examples/queues.ts +254 -0
  80. package/docs/examples/rate-limiting.ts +42 -0
  81. package/docs/examples/redis.ts +99 -0
  82. package/docs/examples/request-response.ts +197 -0
  83. package/docs/examples/routing.ts +186 -0
  84. package/docs/examples/scheduling.ts +62 -0
  85. package/docs/examples/sessions.ts +102 -0
  86. package/docs/examples/static-files.ts +63 -0
  87. package/docs/examples/storage.ts +132 -0
  88. package/docs/examples/templates.ts +58 -0
  89. package/docs/examples/testing.ts +66 -0
  90. package/docs/examples/transformer.ts +141 -0
  91. package/docs/examples/transformers.ts +49 -0
  92. package/docs/examples/url-builder.ts +86 -0
  93. package/docs/examples/validation.ts +102 -0
  94. package/docs/examples/views.tsx +62 -0
  95. package/docs/examples/vite.ts +106 -0
  96. package/docs/factories.md +166 -0
  97. package/docs/getting-started.md +290 -0
  98. package/docs/hashing.md +259 -0
  99. package/docs/health.md +225 -0
  100. package/docs/helpers.md +347 -0
  101. package/docs/hono.md +186 -0
  102. package/docs/hooks.md +118 -0
  103. package/docs/inertia.md +241 -0
  104. package/docs/locks.md +323 -0
  105. package/docs/logger.md +290 -0
  106. package/docs/mail.md +678 -0
  107. package/docs/middleware.md +425 -0
  108. package/docs/migrations.md +476 -0
  109. package/docs/models.md +810 -0
  110. package/docs/notifications.md +474 -0
  111. package/docs/providers.md +363 -0
  112. package/docs/queues.md +679 -0
  113. package/docs/rate-limiting.md +155 -0
  114. package/docs/redis.md +178 -0
  115. package/docs/request-response.md +953 -0
  116. package/docs/routing.md +804 -0
  117. package/docs/scheduling.md +110 -0
  118. package/docs/security.md +85 -0
  119. package/docs/sessions.md +354 -0
  120. package/docs/social-auth.md +174 -0
  121. package/docs/static-files.md +211 -0
  122. package/docs/storage.md +450 -0
  123. package/docs/templates.md +315 -0
  124. package/docs/testing.md +125 -0
  125. package/docs/transformers.md +381 -0
  126. package/docs/url-builder.md +295 -0
  127. package/docs/validation.md +288 -0
  128. package/docs/views.md +267 -0
  129. package/docs/vite.md +434 -0
  130. package/llms-full.txt +17694 -0
  131. package/llms.txt +116 -0
  132. package/package.json +26 -7
package/AGENTS.md ADDED
@@ -0,0 +1,167 @@
1
+ # AGENTS.md — working in Keel with an AI agent
2
+
3
+ Guidance for AI coding agents (Claude Code, Cursor, …) editing **Keel** — the
4
+ house framework for Node.js — or an app built on it. Humans: this is also a fast
5
+ orientation. For prose guides see [`docs/`](./docs); for a machine-readable
6
+ surface, run the [MCP server](#mcp-server-recommended).
7
+
8
+ > **The fastest path:** connect the Keel MCP server (below) and call
9
+ > `keel_overview` first. It returns conventions, the folder map, every doc
10
+ > topic, and the generators. Then `keel_search_docs` / `keel_read_doc` for
11
+ > depth, `keel_search_api` to find exports, `keel_scaffold` to generate code.
12
+
13
+ ---
14
+
15
+ ## What Keel is
16
+
17
+ A small, legible MVC framework: a real **service container**, **service
18
+ providers**, dot-notation **config**, expressive **routing**, active-record
19
+ **models**, a **queue**, **auth**, and a code-generating **console**.
20
+ [Hono](https://hono.dev) powers HTTP; everything above it is Keel's. The whole
21
+ core is a few hundred lines per file in `src/core/` — read it, nothing is magic.
22
+
23
+ ## The one import rule
24
+
25
+ Everything userland comes from a single entry point:
26
+
27
+ ```ts
28
+ import { Router, Model, config, ServiceProvider } from "@shaferllc/keel/core";
29
+ ```
30
+
31
+ - **In a consuming app** (the `keel-app` starter, or your own): use
32
+ `@shaferllc/keel/core`. This is what generated stubs emit.
33
+ - **Inside this framework repo's example app** (`app/`, `routes/`, `bootstrap/`):
34
+ the code uses the `@keel/core` tsconfig **path alias** that points at
35
+ `src/core/`. Match the file you're editing — don't "fix" `@keel/core` to the
36
+ package name inside this repo.
37
+
38
+ Never deep-import `@shaferllc/keel/core/model.js`. If a symbol isn't exported
39
+ from `/core`, that's intentional. Find exports with `keel_search_api` or read
40
+ `src/core/index.ts`.
41
+
42
+ ## Folder layout of a Keel app
43
+
44
+ | Path | What lives there |
45
+ |------|------------------|
46
+ | `app/Controllers/` | Controller classes (resolved from the container → DI) |
47
+ | `app/Providers/` | Service providers (`register()` then `boot()`) |
48
+ | `app/Http/Middleware/` | Hono middleware handlers |
49
+ | `app/Http/Kernel.ts` | The HTTP kernel — global middleware, error handling |
50
+ | `app/Models/` | Active-record models extending `Model` |
51
+ | `app/Jobs/`, `app/Notifications/`, `app/Transformers/` | Queue jobs, notifications, API serializers |
52
+ | `config/*.ts` | Config files; read via `config("app.name")` |
53
+ | `routes/web.ts` | Route definitions — default export receives the `Router` |
54
+ | `database/factories/`, `database/seeders/`, `database/migrations/` | Data tooling |
55
+ | `bootstrap/app.ts` | Assembles the `Application`, boots providers, loads routes |
56
+ | `.env` | Environment, loaded into `env(...)` and config |
57
+
58
+ ## Core mental model
59
+
60
+ 1. **Container.** Every service is registered in and resolved out of the
61
+ container. `bind` (transient), `singleton` (once), `instance` (existing
62
+ value), `make(Token)` (resolve). Constructor injection: a controller/service
63
+ with `constructor(private app: Container)` gets it automatically.
64
+ 2. **Providers.** `register()` binds things into the container; `boot()` runs
65
+ after all providers registered, to wire things together. Register providers
66
+ in `bootstrap/providers.ts`.
67
+ 3. **Config.** `config("app.port", 3000)` reads `config/app.ts` merged with
68
+ `.env`. Dot-notation, with a default.
69
+ 4. **Routing.** `router.get("/x", [Controller, "method"])` or a closure. Name
70
+ routes with `.name()`, group with `.group()`, constrain params with
71
+ `.where()`, build URLs with `router.url("name", params)`.
72
+ 5. **Request context.** Inside a handler, `param()`, `query()`, `body()`,
73
+ `json()`, `text()` read/write the current request via async-local context —
74
+ no need to thread `c` everywhere (though the Hono `Ctx` is available).
75
+
76
+ ## How to add things (prefer the generators)
77
+
78
+ Use `keel_scaffold` (MCP) or the console — both emit the correct stub with the
79
+ right imports and target path. Then wire it up.
80
+
81
+ | Goal | Command | Then |
82
+ |------|---------|------|
83
+ | Controller | `keel make:controller Post` (`-r` for REST) | add a route in `routes/web.ts` |
84
+ | Provider | `keel make:provider Billing` | register in `bootstrap/providers.ts` |
85
+ | Middleware | `keel make:middleware Auth` | attach in `app/Http/Kernel.ts` or per-route |
86
+ | Model | *(no generator — extend `Model`)* | set `static table`; add relations |
87
+ | Factory | `keel make:factory User` | reference the model |
88
+ | Seeder | `keel make:seeder Database` | call factories in `run()` |
89
+ | Job | `keel make:job SendWelcome` | `dispatch(new SendWelcomeJob())` |
90
+ | Notification | `keel make:notification Paid` | `notify(user, new PaidNotification())` |
91
+ | Transformer | `keel make:transformer User` | return it from a controller |
92
+
93
+ **Scaffolding does not write files** via MCP — it returns code + path; you write
94
+ it. The console (`keel make:*`) does write, and refuses to overwrite.
95
+
96
+ A minimal controller and route:
97
+
98
+ ```ts
99
+ // app/Controllers/PostController.ts
100
+ import type { Ctx } from "@shaferllc/keel/core";
101
+ export class PostController {
102
+ index(c: Ctx) { return c.json({ ok: true }); }
103
+ }
104
+
105
+ // routes/web.ts
106
+ router.get("/posts", [PostController, "index"]).name("posts.index");
107
+ ```
108
+
109
+ ## Commands
110
+
111
+ ```bash
112
+ npm run dev # example app on http://localhost:3000 (tsx watch)
113
+ npm run serve # keel serve
114
+ npm run keel -- routes # list registered routes
115
+ npm run keel -- make:controller Foo
116
+ npm run mcp # start the MCP server over stdio (dev)
117
+ npm test # node --test over tests/*.test.ts
118
+ npm run typecheck # tsc --noEmit — run before you finish
119
+ npm run build # regenerate AI artifacts + compile to dist/
120
+ npm run build:ai # regenerate llms.txt, llms-full.txt, docs/ai-manifest.json
121
+ ```
122
+
123
+ ## Conventions & guardrails
124
+
125
+ - **TypeScript, strict, ESM.** `.js` extensions in relative imports (NodeNext).
126
+ No default exports except where the pattern already uses them (routes).
127
+ - **Match the surrounding file** — comment density, naming, idioms. Keel prizes
128
+ legibility; write code that reads like `src/core/`.
129
+ - **Test with the real thing.** `TestClient`/`testClient` drives the HTTP stack
130
+ in-process; `hash.fake()`, `fakeDisk()`, `ArrayTransport`, `MemoryDriver`, and
131
+ `EventBuffer` make side-effecting subsystems deterministic. See
132
+ [`docs/testing.md`](./docs/testing.md).
133
+ - **Run `npm run typecheck` before finishing.** The whole framework is typed end
134
+ to end; a green `tsc` is the bar.
135
+ - **After editing docs or the export surface, run `npm run build:ai`** so
136
+ `llms.txt`, `llms-full.txt`, and `docs/ai-manifest.json` stay in sync (the MCP
137
+ server reads the manifest).
138
+
139
+ ## MCP server (recommended)
140
+
141
+ Keel ships an MCP server exposing its docs, the full public API surface, the
142
+ generators, and framework conventions to any MCP-capable agent.
143
+
144
+ **Claude Code:**
145
+ ```bash
146
+ claude mcp add keel -- npx -y keel-mcp # in an app that depends on @shaferllc/keel
147
+ ```
148
+ Or, hacking on the framework itself:
149
+ ```bash
150
+ claude mcp add keel -- npm --prefix /path/to/keel run mcp
151
+ ```
152
+
153
+ **`.mcp.json` / Cursor / other clients:**
154
+ ```json
155
+ { "mcpServers": { "keel": { "command": "npx", "args": ["-y", "keel-mcp"] } } }
156
+ ```
157
+
158
+ Tools: `keel_overview`, `keel_search_docs`, `keel_read_doc`, `keel_search_api`,
159
+ `keel_list_generators`, `keel_scaffold`. Resources: `keel://overview`,
160
+ `keel://llms-full`, `keel://docs/<slug>`. See [`docs/ai.md`](./docs/ai.md).
161
+
162
+ ## Where to read next
163
+
164
+ - [`docs/getting-started.md`](./docs/getting-started.md) — a guided first hour
165
+ - [`docs/architecture.md`](./docs/architecture.md) — a request from socket to response
166
+ - [`docs/container.md`](./docs/container.md) — the DI core everything rests on
167
+ - [`llms-full.txt`](./llms-full.txt) — every guide in one file, for a fresh context
package/README.md CHANGED
@@ -108,11 +108,38 @@ npm run keel serve --port 8080 # start the server on a chosen port
108
108
  npm run keel make:controller Post # -> app/Controllers/PostController.ts
109
109
  npm run keel make:provider Billing # -> app/Providers/BillingServiceProvider.ts
110
110
  npm run keel make:middleware Auth # -> app/Http/Middleware/authMiddleware.ts
111
+ npm run keel mcp # start the MCP server (docs + API for AI agents)
111
112
  ```
112
113
 
113
114
  Under the hood the console binary is `bin/keel.ts`; the npm scripts wrap it with
114
115
  `tsx`.
115
116
 
117
+ ## Built for AI ⚓🤖
118
+
119
+ Keel is designed to be **written with an AI agent**. Alongside the human docs it
120
+ ships a machine-readable surface that stays generated-in-sync, never stale:
121
+
122
+ - **An MCP server.** `keel-mcp` exposes Keel's docs, its full public API (380+
123
+ exports), the generators, and its conventions to any [MCP](https://modelcontextprotocol.io)
124
+ client. Connect it in Claude Code:
125
+ ```bash
126
+ claude mcp add keel -- npx -y keel-mcp
127
+ ```
128
+ Tools: `keel_overview`, `keel_search_docs`, `keel_read_doc`, `keel_search_api`,
129
+ `keel_list_generators`, `keel_scaffold`. Resources: `keel://overview`,
130
+ `keel://llms-full`, `keel://docs/<slug>`.
131
+ - **[`AGENTS.md`](./AGENTS.md).** The agent playbook — the one import rule, the
132
+ folder map, the container/provider model, a "how to add X" table, and the
133
+ guardrails. `CLAUDE.md` points to it.
134
+ - **[`llms.txt`](./llms.txt) + [`llms-full.txt`](./llms-full.txt).** A
135
+ [spec-compliant](https://llmstxt.org) doc index and a one-file concatenation of
136
+ every guide, both shipped in the npm package for drop-in context.
137
+ - **Generators an agent can drive.** `keel_scaffold` (or `keel make:*`) emits the
138
+ correct stub with the right imports and path for every construct.
139
+
140
+ Full guide: **[docs/ai.md](./docs/ai.md)**. Regenerate the surface after doc or
141
+ export changes with `npm run build:ai` (also runs automatically on `npm run build`).
142
+
116
143
  ## Project layout
117
144
 
118
145
  ```
@@ -191,7 +218,8 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
191
218
  | [Redis](./docs/redis.md) | Pluggable client, memory driver, cache adapter |
192
219
  | [Logger](./docs/logger.md) | Structured logging, per-request `reqId`, redaction |
193
220
  | [Static Files](./docs/static-files.md) | serveStatic(), caching, dot-file safety |
194
- | [Storage](./docs/storage.md) | Pluggable disks (local/S3/R2), edge-safe |
221
+ | [Storage](./docs/storage.md) | Pluggable disks (local/S3/R2), signed URLs, direct uploads |
222
+ | [Health Checks](./docs/health.md) | `/health/live` + `/health/ready`, pluggable checks |
195
223
  | [Views](./docs/views.md) | Hono JSX components, layouts, the View service |
196
224
  | [Templates](./docs/templates.md) | `{{ }}` + `@`-tag templating engine, edge-safe |
197
225
  | [Middleware](./docs/middleware.md) | Global middleware, writing your own |
@@ -205,6 +233,7 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
205
233
  | [The Console](./docs/console.md) | `serve`, `routes`, `make:*` generators |
206
234
  | [Architecture](./docs/architecture.md) | Container, kernel, request lifecycle |
207
235
  | [Built on Hono](./docs/hono.md) | The Hono layer underneath, and using it directly |
236
+ | [Building with AI](./docs/ai.md) | MCP server, `AGENTS.md`, `llms.txt`, agent workflow |
208
237
 
209
238
  ## Testing
210
239
 
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // Published entry for the Keel MCP server. Registered as the `keel-mcp` bin,
3
+ // so an installed app can wire it up with `npx keel-mcp` (see docs/ai.md).
4
+ import { runMcpServer } from "../dist/mcp/server.js";
5
+
6
+ runMcpServer().catch((err) => {
7
+ console.error(err);
8
+ process.exit(1);
9
+ });
@@ -12,7 +12,7 @@ import { Config, type ConfigData } from "./config.js";
12
12
  import { Router } from "./http/router.js";
13
13
  import { View } from "./view.js";
14
14
  import { type ProviderClass } from "./provider.js";
15
- import type { Listener } from "./events.js";
15
+ import type { Listener, EventName, EmitArgs, Resolve } from "./events.js";
16
16
  /** A configurator: a plain function that sets the app up and may return anything. */
17
17
  export type Configurator = (app: Application) => unknown;
18
18
  export interface BootOptions {
@@ -58,13 +58,13 @@ export declare class Application extends Container {
58
58
  * `app.on(...)` and the global `listen()` helper reach the same emitter.
59
59
  * Returns an unsubscribe function.
60
60
  */
61
- on<T = unknown>(event: string, listener: Listener<T>): () => void;
61
+ on<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): () => void;
62
62
  /** Subscribe for a single emission. Returns an unsubscribe function. */
63
- once<T = unknown>(event: string, listener: Listener<T>): () => void;
63
+ once<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): () => void;
64
64
  /** Unsubscribe a listener registered with `on()`/`once()`. */
65
- off<T = unknown>(event: string, listener: Listener<T>): this;
65
+ off<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): this;
66
66
  /** Emit an app event, awaiting every listener in registration order. */
67
- emit<T = unknown>(event: string, payload?: T): Promise<void>;
67
+ emit<T = unknown, E extends EventName = EventName>(event: E, ...args: EmitArgs<Resolve<T, E>>): Promise<void>;
68
68
  /** Merge a config object into the repository under its top-level keys. */
69
69
  private mergeConfig;
70
70
  /** Load .env via dotenv (Node only; no-op elsewhere). */
@@ -95,8 +95,8 @@ export class Application extends Container {
95
95
  return this;
96
96
  }
97
97
  /** Emit an app event, awaiting every listener in registration order. */
98
- async emit(event, payload) {
99
- await this.make(Events).emit(event, payload);
98
+ async emit(event, ...args) {
99
+ await this.make(Events).emit(event, ...args);
100
100
  }
101
101
  /** Merge a config object into the repository under its top-level keys. */
102
102
  mergeConfig(data) {
@@ -5,6 +5,23 @@
5
5
  * the global `cache()` helper.
6
6
  *
7
7
  * const stats = await cache().remember("stats", 60, () => computeStats());
8
+ *
9
+ * Features borrowed from bentocache (AdonisJS' cache), kept inside keel's
10
+ * single-store, edge-safe model:
11
+ *
12
+ * - Stampede protection — concurrent `remember()` calls for the same cold key
13
+ * run the factory ONCE and share the result, instead of dog-piling.
14
+ * - Grace / stale-on-error — with a `grace` window, an expired value is kept
15
+ * a little longer and served if the refreshing factory throws.
16
+ * - Tags — group entries and invalidate them together with `deleteByTag()`.
17
+ * - Namespaces — `namespace("users")` scopes keys under a prefix, and its
18
+ * `flush()` clears only that namespace.
19
+ *
20
+ * Tag and namespace invalidation use version stamps, not a key index: each tag
21
+ * carries a counter, every entry records the counter values it was written at,
22
+ * and invalidation just bumps the counter — so an entry whose tag has moved on
23
+ * reads as a miss. O(1) invalidation, no key-set bookkeeping, works on any
24
+ * `CacheStore`.
8
25
  */
9
26
  export interface CacheStore {
10
27
  get(key: string): Promise<unknown> | unknown;
@@ -20,22 +37,82 @@ export declare class MemoryStore implements CacheStore {
20
37
  delete(key: string): void;
21
38
  clear(): void;
22
39
  }
40
+ /** Options for `put`/`add`. */
41
+ export interface PutOptions {
42
+ /** Tags to associate with this entry, for `deleteByTag` invalidation. */
43
+ tags?: string[];
44
+ }
45
+ /** Options for the `remember` family. */
46
+ export interface RememberOptions extends PutOptions {
47
+ /**
48
+ * Grace window in seconds. After the value's TTL lapses, keep it this much
49
+ * longer and serve it if the refreshing factory throws (stale-on-error).
50
+ */
51
+ grace?: number;
52
+ }
23
53
  export declare class Cache {
24
54
  private store;
25
55
  constructor(store?: CacheStore);
56
+ private prefix;
57
+ private nsTag?;
58
+ private inflight;
59
+ /** Apply this cache's namespace prefix to a logical key. */
60
+ private k;
61
+ private tagKey;
62
+ private tagVersion;
63
+ /** Snapshot the current version of each tag, to stamp onto a new entry. */
64
+ private stampTags;
65
+ /** True if any recorded tag version is behind the live one (invalidated). */
66
+ private tagsInvalidated;
67
+ /**
68
+ * Read the raw envelope for a key. Returns undefined when absent OR when a tag
69
+ * has invalidated it — tag/namespace invalidation is a hard miss (not grace-
70
+ * eligible). TTL freshness is left to callers, so grace can still see a stale
71
+ * entry.
72
+ */
73
+ private entry;
74
+ private write;
26
75
  get<T = unknown>(key: string, fallback?: T): Promise<T>;
27
- /** Store a value, optionally expiring after `ttlSeconds`. */
28
- put(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
76
+ /** Store a value, optionally expiring after `ttlSeconds`, with optional tags. */
77
+ put(key: string, value: unknown, ttlSeconds?: number, options?: PutOptions): Promise<void>;
78
+ /** Store a value only if the key is absent. Returns whether it was written. */
79
+ add(key: string, value: unknown, ttlSeconds?: number, options?: PutOptions): Promise<boolean>;
29
80
  has(key: string): Promise<boolean>;
81
+ /** The inverse of `has` — true when the key is absent or expired. */
82
+ missing(key: string): Promise<boolean>;
30
83
  forget(key: string): Promise<void>;
84
+ /** Forget several keys at once. */
85
+ forgetMany(keys: string[]): Promise<void>;
31
86
  /** Read and remove a value. */
32
87
  pull<T = unknown>(key: string, fallback?: T): Promise<T>;
33
88
  flush(): Promise<void>;
89
+ /**
90
+ * Invalidate every entry carrying any of these tags by bumping the tag's
91
+ * version — entries stamped with the old version then read as a miss. O(#tags),
92
+ * no key scan; invalidated entries fall out on their own TTL.
93
+ */
94
+ deleteByTag(tags: string[]): Promise<void>;
95
+ /**
96
+ * A cache scoped under a key prefix. Keys written through it live at
97
+ * `name:key`, and its `flush()` clears only this namespace (via an implicit
98
+ * tag) — the rest of the store is untouched. Namespaces nest.
99
+ *
100
+ * const users = cache().namespace("users");
101
+ * await users.put("1", user); // stored at "users:1"
102
+ * await users.flush(); // clears only the users namespace
103
+ */
104
+ namespace(name: string): Cache;
34
105
  /**
35
106
  * Return the cached value, or compute it with `factory`, cache it for
36
107
  * `ttlSeconds`, and return it.
108
+ *
109
+ * Concurrent calls for the same cold key share one factory run (stampede
110
+ * protection). With `{ grace }`, an expired value is retained that many extra
111
+ * seconds and served if the refreshing factory throws (stale-on-error). With
112
+ * `{ tags }`, the cached value joins those tags for `deleteByTag`.
37
113
  */
38
- remember<T>(key: string, ttlSeconds: number, factory: () => T | Promise<T>): Promise<T>;
39
- /** Like remember(), but cached forever (no TTL). */
40
- rememberForever<T>(key: string, factory: () => T | Promise<T>): Promise<T>;
114
+ remember<T>(key: string, ttlSeconds: number, factory: () => T | Promise<T>, options?: RememberOptions): Promise<T>;
115
+ /** Like remember(), but cached forever (no TTL). Also stampede-protected. */
116
+ rememberForever<T>(key: string, factory: () => T | Promise<T>, options?: PutOptions): Promise<T>;
117
+ private resolve;
41
118
  }
@@ -5,6 +5,23 @@
5
5
  * the global `cache()` helper.
6
6
  *
7
7
  * const stats = await cache().remember("stats", 60, () => computeStats());
8
+ *
9
+ * Features borrowed from bentocache (AdonisJS' cache), kept inside keel's
10
+ * single-store, edge-safe model:
11
+ *
12
+ * - Stampede protection — concurrent `remember()` calls for the same cold key
13
+ * run the factory ONCE and share the result, instead of dog-piling.
14
+ * - Grace / stale-on-error — with a `grace` window, an expired value is kept
15
+ * a little longer and served if the refreshing factory throws.
16
+ * - Tags — group entries and invalidate them together with `deleteByTag()`.
17
+ * - Namespaces — `namespace("users")` scopes keys under a prefix, and its
18
+ * `flush()` clears only that namespace.
19
+ *
20
+ * Tag and namespace invalidation use version stamps, not a key index: each tag
21
+ * carries a counter, every entry records the counter values it was written at,
22
+ * and invalidation just bumps the counter — so an entry whose tag has moved on
23
+ * reads as a miss. O(1) invalidation, no key-set bookkeeping, works on any
24
+ * `CacheStore`.
8
25
  */
9
26
  /** The default in-memory store. */
10
27
  export class MemoryStore {
@@ -29,24 +46,109 @@ export class MemoryStore {
29
46
  this.data.clear();
30
47
  }
31
48
  }
49
+ function isEntry(x) {
50
+ return typeof x === "object" && x !== null && x.__keelCache === 1;
51
+ }
52
+ // The store key that holds a tag's version counter. Never namespaced, never
53
+ // expired — a bump must outlive every entry that recorded the old version.
54
+ const TAG_VERSION_PREFIX = "\u0000keel:tagv:";
32
55
  export class Cache {
33
56
  store;
34
57
  constructor(store = new MemoryStore()) {
35
58
  this.store = store;
36
59
  }
60
+ // Key prefix + implicit tag for a namespaced cache (empty on the root).
61
+ prefix = "";
62
+ nsTag;
63
+ // Stampede protection: in-flight factory promises, keyed by prefixed key.
64
+ inflight = new Map();
65
+ /** Apply this cache's namespace prefix to a logical key. */
66
+ k(key) {
67
+ return this.prefix + key;
68
+ }
69
+ tagKey(tag) {
70
+ return TAG_VERSION_PREFIX + tag;
71
+ }
72
+ async tagVersion(tag) {
73
+ const v = await this.store.get(this.tagKey(tag));
74
+ return typeof v === "number" ? v : 0;
75
+ }
76
+ /** Snapshot the current version of each tag, to stamp onto a new entry. */
77
+ async stampTags(tags) {
78
+ const out = {};
79
+ for (const tag of tags)
80
+ out[tag] = await this.tagVersion(tag);
81
+ return out;
82
+ }
83
+ /** True if any recorded tag version is behind the live one (invalidated). */
84
+ async tagsInvalidated(recorded) {
85
+ for (const [tag, ver] of Object.entries(recorded)) {
86
+ if ((await this.tagVersion(tag)) > ver)
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+ /**
92
+ * Read the raw envelope for a key. Returns undefined when absent OR when a tag
93
+ * has invalidated it — tag/namespace invalidation is a hard miss (not grace-
94
+ * eligible). TTL freshness is left to callers, so grace can still see a stale
95
+ * entry.
96
+ */
97
+ async entry(key) {
98
+ const raw = await this.store.get(this.k(key));
99
+ if (raw === undefined)
100
+ return undefined;
101
+ const e = isEntry(raw) ? raw : { __keelCache: 1, v: raw, e: 0 };
102
+ if (e.t && (await this.tagsInvalidated(e.t)))
103
+ return undefined;
104
+ return e;
105
+ }
106
+ async write(key, value, ttlSeconds, graceSeconds = 0, tags = []) {
107
+ const allTags = this.nsTag ? [this.nsTag, ...tags] : tags;
108
+ const now = Date.now();
109
+ const e = ttlSeconds ? now + ttlSeconds * 1000 : 0;
110
+ const storeTtlMs = ttlSeconds ? (ttlSeconds + graceSeconds) * 1000 : undefined;
111
+ const t = allTags.length ? await this.stampTags(allTags) : undefined;
112
+ const entry = { __keelCache: 1, v: value, e };
113
+ if (t)
114
+ entry.t = t;
115
+ await this.store.set(this.k(key), entry, storeTtlMs);
116
+ }
37
117
  async get(key, fallback) {
38
- const value = await this.store.get(key);
39
- return (value === undefined ? fallback : value);
118
+ const entry = await this.entry(key);
119
+ if (!entry)
120
+ return fallback;
121
+ if (entry.e && entry.e < Date.now())
122
+ return fallback; // expired (grace-retained)
123
+ return entry.v;
124
+ }
125
+ /** Store a value, optionally expiring after `ttlSeconds`, with optional tags. */
126
+ async put(key, value, ttlSeconds, options) {
127
+ await this.write(key, value, ttlSeconds, 0, options?.tags ?? []);
40
128
  }
41
- /** Store a value, optionally expiring after `ttlSeconds`. */
42
- async put(key, value, ttlSeconds) {
43
- await this.store.set(key, value, ttlSeconds ? ttlSeconds * 1000 : undefined);
129
+ /** Store a value only if the key is absent. Returns whether it was written. */
130
+ async add(key, value, ttlSeconds, options) {
131
+ if (await this.has(key))
132
+ return false;
133
+ await this.put(key, value, ttlSeconds, options);
134
+ return true;
44
135
  }
45
136
  async has(key) {
46
- return (await this.store.get(key)) !== undefined;
137
+ const entry = await this.entry(key);
138
+ if (!entry)
139
+ return false;
140
+ return !(entry.e && entry.e < Date.now());
141
+ }
142
+ /** The inverse of `has` — true when the key is absent or expired. */
143
+ async missing(key) {
144
+ return !(await this.has(key));
47
145
  }
48
146
  async forget(key) {
49
- await this.store.delete(key);
147
+ await this.store.delete(this.k(key));
148
+ }
149
+ /** Forget several keys at once. */
150
+ async forgetMany(keys) {
151
+ await Promise.all(keys.map((k) => this.store.delete(this.k(k))));
50
152
  }
51
153
  /** Read and remove a value. */
52
154
  async pull(key, fallback) {
@@ -55,27 +157,83 @@ export class Cache {
55
157
  return value;
56
158
  }
57
159
  async flush() {
58
- await this.store.clear();
160
+ // A namespace clears itself by moving its tag version on; the root wipes the
161
+ // whole store.
162
+ if (this.nsTag)
163
+ await this.deleteByTag([this.nsTag]);
164
+ else
165
+ await this.store.clear();
166
+ }
167
+ /**
168
+ * Invalidate every entry carrying any of these tags by bumping the tag's
169
+ * version — entries stamped with the old version then read as a miss. O(#tags),
170
+ * no key scan; invalidated entries fall out on their own TTL.
171
+ */
172
+ async deleteByTag(tags) {
173
+ for (const tag of tags) {
174
+ const current = await this.tagVersion(tag);
175
+ await this.store.set(this.tagKey(tag), current + 1); // no TTL — must persist
176
+ }
177
+ }
178
+ /**
179
+ * A cache scoped under a key prefix. Keys written through it live at
180
+ * `name:key`, and its `flush()` clears only this namespace (via an implicit
181
+ * tag) — the rest of the store is untouched. Namespaces nest.
182
+ *
183
+ * const users = cache().namespace("users");
184
+ * await users.put("1", user); // stored at "users:1"
185
+ * await users.flush(); // clears only the users namespace
186
+ */
187
+ namespace(name) {
188
+ const child = new Cache(this.store);
189
+ child.prefix = `${this.prefix}${name}:`;
190
+ child.nsTag = `\u0000keel:ns:${this.prefix}${name}`;
191
+ return child;
59
192
  }
60
193
  /**
61
194
  * Return the cached value, or compute it with `factory`, cache it for
62
195
  * `ttlSeconds`, and return it.
196
+ *
197
+ * Concurrent calls for the same cold key share one factory run (stampede
198
+ * protection). With `{ grace }`, an expired value is retained that many extra
199
+ * seconds and served if the refreshing factory throws (stale-on-error). With
200
+ * `{ tags }`, the cached value joins those tags for `deleteByTag`.
63
201
  */
64
- async remember(key, ttlSeconds, factory) {
65
- const existing = await this.store.get(key);
66
- if (existing !== undefined)
67
- return existing;
68
- const value = await factory();
69
- await this.put(key, value, ttlSeconds);
70
- return value;
202
+ remember(key, ttlSeconds, factory, options) {
203
+ return this.resolve(key, ttlSeconds, factory, options?.grace ?? 0, options?.tags ?? []);
71
204
  }
72
- /** Like remember(), but cached forever (no TTL). */
73
- async rememberForever(key, factory) {
74
- const existing = await this.store.get(key);
75
- if (existing !== undefined)
76
- return existing;
77
- const value = await factory();
78
- await this.put(key, value);
79
- return value;
205
+ /** Like remember(), but cached forever (no TTL). Also stampede-protected. */
206
+ rememberForever(key, factory, options) {
207
+ return this.resolve(key, undefined, factory, 0, options?.tags ?? []);
208
+ }
209
+ async resolve(key, ttlSeconds, factory, graceSeconds, tags) {
210
+ const entry = await this.entry(key);
211
+ const fresh = entry && (!entry.e || entry.e > Date.now());
212
+ if (fresh)
213
+ return entry.v;
214
+ // Stampede protection: join an in-flight computation for this key.
215
+ const pending = this.inflight.get(key);
216
+ if (pending)
217
+ return pending;
218
+ const run = (async () => {
219
+ try {
220
+ // Defer to a microtask so a synchronously-throwing factory rejects the
221
+ // shared promise only after every joiner has attached its handler.
222
+ const value = await Promise.resolve().then(factory);
223
+ await this.write(key, value, ttlSeconds, graceSeconds, tags);
224
+ return value;
225
+ }
226
+ catch (err) {
227
+ // Grace: a stale-but-retained value rescues a failing refresh.
228
+ if (entry && graceSeconds > 0)
229
+ return entry.v;
230
+ throw err;
231
+ }
232
+ finally {
233
+ this.inflight.delete(key);
234
+ }
235
+ })();
236
+ this.inflight.set(key, run);
237
+ return run;
80
238
  }
81
239
  }
@@ -0,0 +1,12 @@
1
+ /** Code-generation templates for `keel make:*`. */
2
+ export declare function controllerStub(name: string): string;
3
+ export declare function resourceControllerStub(name: string): string;
4
+ export declare function providerStub(name: string): string;
5
+ export declare function middlewareStub(name: string): string;
6
+ /** `name` is the model class (e.g. "User") the factory builds. */
7
+ export declare function factoryStub(model: string): string;
8
+ export declare function seederStub(name: string): string;
9
+ export declare function jobStub(name: string): string;
10
+ export declare function notificationStub(name: string): string;
11
+ /** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
12
+ export declare function transformerStub(name: string, model: string): string;