@shaferllc/keel 0.66.0 → 0.74.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 (239) hide show
  1. package/AGENTS.md +169 -0
  2. package/README.md +44 -6
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/api/api.config.stub +9 -0
  5. package/dist/api/config.d.ts +13 -0
  6. package/dist/api/config.js +14 -0
  7. package/dist/api/index.d.ts +16 -0
  8. package/dist/api/index.js +13 -0
  9. package/dist/api/provider.d.ts +10 -0
  10. package/dist/api/provider.js +17 -0
  11. package/dist/api/query.d.ts +35 -0
  12. package/dist/api/query.js +42 -0
  13. package/dist/api/resource.d.ts +91 -0
  14. package/dist/api/resource.js +188 -0
  15. package/dist/core/application.d.ts +5 -5
  16. package/dist/core/application.js +8 -2
  17. package/dist/core/cache.d.ts +81 -5
  18. package/dist/core/cache.js +188 -23
  19. package/dist/core/cli/stubs.d.ts +26 -0
  20. package/dist/core/cli/stubs.js +225 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/events.d.ts +129 -5
  32. package/dist/core/events.js +165 -7
  33. package/dist/core/health.d.ts +141 -0
  34. package/dist/core/health.js +226 -0
  35. package/dist/core/helpers.d.ts +9 -3
  36. package/dist/core/helpers.js +11 -3
  37. package/dist/core/http/kernel.d.ts +2 -0
  38. package/dist/core/http/kernel.js +48 -0
  39. package/dist/core/http/router.d.ts +5 -5
  40. package/dist/core/http/router.js +5 -5
  41. package/dist/core/i18n.d.ts +162 -0
  42. package/dist/core/i18n.js +472 -0
  43. package/dist/core/index.d.ts +39 -13
  44. package/dist/core/index.js +19 -6
  45. package/dist/core/instrumentation.d.ts +113 -0
  46. package/dist/core/instrumentation.js +52 -0
  47. package/dist/core/lock.d.ts +139 -0
  48. package/dist/core/lock.js +215 -0
  49. package/dist/core/logger.d.ts +89 -4
  50. package/dist/core/logger.js +167 -22
  51. package/dist/core/mail.d.ts +128 -7
  52. package/dist/core/mail.js +264 -16
  53. package/dist/core/notification.js +10 -1
  54. package/dist/core/package.d.ts +120 -0
  55. package/dist/core/package.js +169 -0
  56. package/dist/core/pages.d.ts +108 -0
  57. package/dist/core/pages.js +199 -0
  58. package/dist/core/queue.d.ts +134 -9
  59. package/dist/core/queue.js +325 -14
  60. package/dist/core/repl.d.ts +33 -0
  61. package/dist/core/repl.js +88 -0
  62. package/dist/core/scheduler.js +6 -0
  63. package/dist/core/social.d.ts +4 -4
  64. package/dist/core/social.js +4 -4
  65. package/dist/core/storage.d.ts +159 -6
  66. package/dist/core/storage.js +299 -7
  67. package/dist/core/telemetry.d.ts +208 -0
  68. package/dist/core/telemetry.js +383 -0
  69. package/dist/core/template.d.ts +2 -3
  70. package/dist/core/template.js +2 -3
  71. package/dist/core/testing.d.ts +170 -1
  72. package/dist/core/testing.js +504 -2
  73. package/dist/db/d1.js +13 -0
  74. package/dist/db/pg.d.ts +13 -0
  75. package/dist/db/pg.js +46 -4
  76. package/dist/mcp/server.d.ts +19 -0
  77. package/dist/mcp/server.js +355 -0
  78. package/dist/openapi/config.d.ts +28 -0
  79. package/dist/openapi/config.js +25 -0
  80. package/dist/openapi/doc.d.ts +40 -0
  81. package/dist/openapi/doc.js +20 -0
  82. package/dist/openapi/export.d.ts +8 -0
  83. package/dist/openapi/export.js +19 -0
  84. package/dist/openapi/gate.d.ts +15 -0
  85. package/dist/openapi/gate.js +27 -0
  86. package/dist/openapi/index.d.ts +19 -0
  87. package/dist/openapi/index.js +15 -0
  88. package/dist/openapi/openapi.config.stub +29 -0
  89. package/dist/openapi/provider.d.ts +18 -0
  90. package/dist/openapi/provider.js +35 -0
  91. package/dist/openapi/routes.d.ts +9 -0
  92. package/dist/openapi/routes.js +23 -0
  93. package/dist/openapi/spec.d.ts +23 -0
  94. package/dist/openapi/spec.js +132 -0
  95. package/dist/openapi/ui.d.ts +8 -0
  96. package/dist/openapi/ui.js +31 -0
  97. package/dist/openapi/zod.d.ts +12 -0
  98. package/dist/openapi/zod.js +46 -0
  99. package/dist/watch/config.d.ts +33 -0
  100. package/dist/watch/config.js +38 -0
  101. package/dist/watch/entry.d.ts +53 -0
  102. package/dist/watch/entry.js +105 -0
  103. package/dist/watch/gate.d.ts +20 -0
  104. package/dist/watch/gate.js +32 -0
  105. package/dist/watch/index.d.ts +21 -0
  106. package/dist/watch/index.js +17 -0
  107. package/dist/watch/migration.d.ts +7 -0
  108. package/dist/watch/migration.js +20 -0
  109. package/dist/watch/provider.d.ts +22 -0
  110. package/dist/watch/provider.js +58 -0
  111. package/dist/watch/prune.d.ts +11 -0
  112. package/dist/watch/prune.js +20 -0
  113. package/dist/watch/recorder.d.ts +24 -0
  114. package/dist/watch/recorder.js +39 -0
  115. package/dist/watch/routes.d.ts +13 -0
  116. package/dist/watch/routes.js +55 -0
  117. package/dist/watch/store.d.ts +54 -0
  118. package/dist/watch/store.js +158 -0
  119. package/dist/watch/ui/dist/watch.css +1 -0
  120. package/dist/watch/ui/dist/watch.js +555 -0
  121. package/dist/watch/ui-shell.d.ts +12 -0
  122. package/dist/watch/ui-shell.js +24 -0
  123. package/dist/watch/watch.config.stub +47 -0
  124. package/dist/watch/watchers.d.ts +12 -0
  125. package/dist/watch/watchers.js +156 -0
  126. package/docs/ai-manifest.json +3206 -0
  127. package/docs/ai.md +128 -0
  128. package/docs/api-resources.md +118 -0
  129. package/docs/architecture.md +331 -0
  130. package/docs/authentication.md +453 -0
  131. package/docs/authorization.md +167 -0
  132. package/docs/broadcasting.md +137 -0
  133. package/docs/broker.md +500 -0
  134. package/docs/cache.md +558 -0
  135. package/docs/configuration.md +385 -0
  136. package/docs/console.md +536 -0
  137. package/docs/container.md +467 -0
  138. package/docs/controllers.md +265 -0
  139. package/docs/cors.md +51 -0
  140. package/docs/database.md +631 -0
  141. package/docs/debugging.md +129 -0
  142. package/docs/decorators.md +127 -0
  143. package/docs/errors.md +395 -0
  144. package/docs/events.md +496 -0
  145. package/docs/examples/architecture-app.ts +27 -0
  146. package/docs/examples/authentication.ts +61 -0
  147. package/docs/examples/authorization.ts +79 -0
  148. package/docs/examples/broadcasting.ts +60 -0
  149. package/docs/examples/broker-cache-validate.ts +34 -0
  150. package/docs/examples/broker-fault-tolerance.ts +29 -0
  151. package/docs/examples/broker-middleware.ts +27 -0
  152. package/docs/examples/broker.ts +203 -0
  153. package/docs/examples/cache.ts +222 -0
  154. package/docs/examples/configuration.ts +121 -0
  155. package/docs/examples/console.ts +134 -0
  156. package/docs/examples/container.ts +134 -0
  157. package/docs/examples/controllers.ts +86 -0
  158. package/docs/examples/database.ts +208 -0
  159. package/docs/examples/debugging.ts +41 -0
  160. package/docs/examples/decorators.ts +40 -0
  161. package/docs/examples/errors.ts +121 -0
  162. package/docs/examples/events.ts +204 -0
  163. package/docs/examples/factories.ts +84 -0
  164. package/docs/examples/hashing.ts +71 -0
  165. package/docs/examples/health.ts +94 -0
  166. package/docs/examples/helpers.ts +171 -0
  167. package/docs/examples/hooks.ts +54 -0
  168. package/docs/examples/i18n.ts +117 -0
  169. package/docs/examples/inertia.ts +81 -0
  170. package/docs/examples/locks.ts +120 -0
  171. package/docs/examples/logger.ts +166 -0
  172. package/docs/examples/mail.ts +263 -0
  173. package/docs/examples/middleware.ts +119 -0
  174. package/docs/examples/migrations.ts +126 -0
  175. package/docs/examples/models.ts +239 -0
  176. package/docs/examples/notification.ts +124 -0
  177. package/docs/examples/pages.ts +82 -0
  178. package/docs/examples/providers.ts +123 -0
  179. package/docs/examples/queues.ts +254 -0
  180. package/docs/examples/rate-limiting.ts +42 -0
  181. package/docs/examples/redis.ts +99 -0
  182. package/docs/examples/request-response.ts +197 -0
  183. package/docs/examples/routing.ts +186 -0
  184. package/docs/examples/scheduling.ts +62 -0
  185. package/docs/examples/sessions.ts +102 -0
  186. package/docs/examples/static-files.ts +63 -0
  187. package/docs/examples/storage.ts +132 -0
  188. package/docs/examples/telemetry.ts +127 -0
  189. package/docs/examples/templates.ts +58 -0
  190. package/docs/examples/testing.ts +215 -0
  191. package/docs/examples/transformer.ts +141 -0
  192. package/docs/examples/transformers.ts +49 -0
  193. package/docs/examples/url-builder.ts +86 -0
  194. package/docs/examples/validation.ts +102 -0
  195. package/docs/examples/views.tsx +62 -0
  196. package/docs/examples/vite.ts +106 -0
  197. package/docs/factories.md +166 -0
  198. package/docs/getting-started.md +290 -0
  199. package/docs/hashing.md +259 -0
  200. package/docs/health.md +225 -0
  201. package/docs/helpers.md +347 -0
  202. package/docs/hono.md +186 -0
  203. package/docs/hooks.md +118 -0
  204. package/docs/i18n.md +302 -0
  205. package/docs/inertia.md +241 -0
  206. package/docs/locks.md +323 -0
  207. package/docs/logger.md +436 -0
  208. package/docs/mail.md +751 -0
  209. package/docs/middleware.md +425 -0
  210. package/docs/migrations.md +476 -0
  211. package/docs/models.md +810 -0
  212. package/docs/notifications.md +474 -0
  213. package/docs/openapi.md +111 -0
  214. package/docs/packages.md +118 -0
  215. package/docs/pages.md +217 -0
  216. package/docs/providers.md +363 -0
  217. package/docs/queues.md +679 -0
  218. package/docs/rate-limiting.md +155 -0
  219. package/docs/redis.md +178 -0
  220. package/docs/request-response.md +953 -0
  221. package/docs/routing.md +804 -0
  222. package/docs/scheduling.md +110 -0
  223. package/docs/security.md +85 -0
  224. package/docs/sessions.md +354 -0
  225. package/docs/social-auth.md +174 -0
  226. package/docs/static-files.md +211 -0
  227. package/docs/storage.md +452 -0
  228. package/docs/telemetry.md +263 -0
  229. package/docs/templates.md +314 -0
  230. package/docs/testing.md +376 -0
  231. package/docs/transformers.md +381 -0
  232. package/docs/url-builder.md +295 -0
  233. package/docs/validation.md +288 -0
  234. package/docs/views.md +267 -0
  235. package/docs/vite.md +434 -0
  236. package/docs/watch.md +118 -0
  237. package/llms-full.txt +19816 -0
  238. package/llms.txt +127 -0
  239. package/package.json +42 -7
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `apiResource(router, Model, options)` — a full CRUD REST API from a Keel model,
3
+ * the Remult idea done the Keel way: explicit, server-side, and composed from
4
+ * pieces you already have. It registers real routes (so the OpenAPI package
5
+ * documents them for free), runs writes through the model's mass-assignment guard
6
+ * and your Zod schema, paginates and allow-list-filters reads, and gates every
7
+ * action.
8
+ *
9
+ * apiResource(router, Post, {
10
+ * filter: ["status", "authorId"],
11
+ * sort: ["createdAt", "title"],
12
+ * body: PostSchema,
13
+ * access: { read: true, write: (c) => isEditor(c) },
14
+ * scope: (q) => q.where("deleted", false),
15
+ * });
16
+ *
17
+ * Access is **deny by default**: an action with no `access` rule returns 403.
18
+ * That's the safe default for a generated API — you opt routes open, never shut.
19
+ */
20
+ import { validate } from "../core/validation.js";
21
+ import { ForbiddenException, NotFoundException } from "../core/exceptions.js";
22
+ import { apiDefaults } from "./config.js";
23
+ import { parseListParams, applyListParams } from "./query.js";
24
+ /**
25
+ * The route-config key `@shaferllc/keel/openapi` reads operation docs from. It's
26
+ * declared here — not imported from openapi — so this package never depends on
27
+ * openapi: install the two together and routes are documented; install only api
28
+ * and the metadata is inert. Keep this value in sync with openapi's `OPENAPI_KEY`.
29
+ */
30
+ const OPENAPI_CONFIG_KEY = "openapi";
31
+ /** Wrap operation docs for a route's `.config()`, without importing openapi. */
32
+ function openApiConfig(doc) {
33
+ return { [OPENAPI_CONFIG_KEY]: doc };
34
+ }
35
+ const ALL_ACTIONS = ["list", "read", "create", "update", "delete"];
36
+ function resolveActions(options) {
37
+ let actions = options.only ?? ALL_ACTIONS;
38
+ if (options.except)
39
+ actions = actions.filter((a) => !options.except.includes(a));
40
+ return new Set(actions);
41
+ }
42
+ /** Resolve the access rule for an action; undefined (no rule) means deny. */
43
+ function ruleFor(access, action) {
44
+ const a = access ?? {};
45
+ switch (action) {
46
+ case "list":
47
+ return a.list ?? a.read ?? a.all;
48
+ case "read":
49
+ return a.get ?? a.read ?? a.all;
50
+ case "create":
51
+ return a.create ?? a.write ?? a.all;
52
+ case "update":
53
+ return a.update ?? a.write ?? a.all;
54
+ case "delete":
55
+ return a.delete ?? a.write ?? a.all;
56
+ }
57
+ }
58
+ /** Evaluate access; throw 403 if denied. */
59
+ async function authorize(access, action, c) {
60
+ const rule = ruleFor(access, action);
61
+ const ok = rule === undefined ? false : typeof rule === "boolean" ? rule : await rule(c);
62
+ if (!ok)
63
+ throw new ForbiddenException();
64
+ }
65
+ function transformOne(t, model, c) {
66
+ if (!t)
67
+ return model.toJSON();
68
+ return typeof t === "function" ? t(model, c) : t.item(model);
69
+ }
70
+ function transformMany(t, models, c) {
71
+ if (!t)
72
+ return models.map((m) => m.toJSON());
73
+ return typeof t === "function" ? models.map((m) => t(m, c)) : t.collection(models);
74
+ }
75
+ function singular(word) {
76
+ return word.endsWith("s") ? word.slice(0, -1) : word;
77
+ }
78
+ export function apiResource(router, model, options = {}) {
79
+ const defaults = apiDefaults();
80
+ const path = options.path ?? model.table;
81
+ const base = "/" + path.replace(/^\/|\/$/g, "");
82
+ const name = options.name ?? path;
83
+ const pk = model.primaryKey;
84
+ const one = singular(path);
85
+ const tags = options.tags ?? [path];
86
+ const perPage = options.perPage ?? defaults.perPage;
87
+ const maxPerPage = options.maxPerPage ?? defaults.maxPerPage;
88
+ const actions = resolveActions(options);
89
+ /** The base query with any row-level scope applied. */
90
+ const scoped = (c) => {
91
+ const q = model.query();
92
+ if (!options.scope)
93
+ return q;
94
+ return options.scope(q, c) ?? q;
95
+ };
96
+ /** Find one row within scope, or 404. */
97
+ const findScoped = async (c) => {
98
+ const row = await scoped(c).where(pk, c.req.param("id")).first();
99
+ if (!row)
100
+ throw new NotFoundException(`${model.name} not found`);
101
+ return new model(row);
102
+ };
103
+ const readBody = async (c, schema) => {
104
+ const raw = (await c.req.json().catch(() => ({})));
105
+ return schema ? (await validate(schema, raw)) : raw;
106
+ };
107
+ if (actions.has("list")) {
108
+ router
109
+ .get(base, async (c) => {
110
+ await authorize(options.access, "list", c);
111
+ const params = parseListParams(c, {
112
+ filter: options.filter ?? [],
113
+ sort: options.sort ?? [],
114
+ perPage,
115
+ maxPerPage,
116
+ });
117
+ const query = applyListParams(scoped(c), params);
118
+ const result = await query.paginate(params.page, params.perPage);
119
+ const models = result.data.map((row) => new model(row));
120
+ return c.json({
121
+ data: transformMany(options.transform, models, c),
122
+ meta: {
123
+ total: result.total,
124
+ perPage: result.perPage,
125
+ currentPage: result.currentPage,
126
+ lastPage: result.lastPage,
127
+ },
128
+ });
129
+ })
130
+ .name(`${name}.list`)
131
+ .config(openApiConfig({ summary: `List ${path}`, tags }));
132
+ }
133
+ if (actions.has("read")) {
134
+ router
135
+ .get(`${base}/:id`, async (c) => {
136
+ await authorize(options.access, "read", c);
137
+ const found = await findScoped(c);
138
+ return c.json({ data: transformOne(options.transform, found, c) });
139
+ })
140
+ .name(`${name}.read`)
141
+ .config(openApiConfig({ summary: `Fetch a ${one}`, tags }));
142
+ }
143
+ if (actions.has("create")) {
144
+ const schema = options.createBody ?? options.body;
145
+ router
146
+ .post(base, async (c) => {
147
+ await authorize(options.access, "create", c);
148
+ let data = await readBody(c, schema);
149
+ if (options.beforeWrite)
150
+ data = await options.beforeWrite(data, c, "create");
151
+ const created = await model.create(data);
152
+ return c.json({ data: transformOne(options.transform, created, c) }, 201);
153
+ })
154
+ .name(`${name}.create`)
155
+ .config(openApiConfig({
156
+ summary: `Create a ${one}`,
157
+ tags,
158
+ ...(schema ? { request: { body: schema } } : {}),
159
+ responses: { 201: { description: `The created ${one}` } },
160
+ }));
161
+ }
162
+ if (actions.has("update")) {
163
+ const schema = options.updateBody ?? options.body;
164
+ router
165
+ .route(["PUT", "PATCH"], `${base}/:id`, async (c) => {
166
+ await authorize(options.access, "update", c);
167
+ const found = await findScoped(c);
168
+ let data = await readBody(c, schema);
169
+ if (options.beforeWrite)
170
+ data = await options.beforeWrite(data, c, "update");
171
+ await found.update(data);
172
+ return c.json({ data: transformOne(options.transform, found, c) });
173
+ })
174
+ .name(`${name}.update`)
175
+ .config(openApiConfig({ summary: `Update a ${one}`, tags, ...(schema ? { request: { body: schema } } : {}) }));
176
+ }
177
+ if (actions.has("delete")) {
178
+ router
179
+ .delete(`${base}/:id`, async (c) => {
180
+ await authorize(options.access, "delete", c);
181
+ const found = await findScoped(c);
182
+ await found.delete();
183
+ return c.body(null, 204);
184
+ })
185
+ .name(`${name}.delete`)
186
+ .config(openApiConfig({ summary: `Delete a ${one}`, tags, responses: { 204: { description: "Deleted" } } }));
187
+ }
188
+ }
@@ -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). */
@@ -14,6 +14,7 @@ import { View } from "./view.js";
14
14
  import { Events } from "./events.js";
15
15
  import { Cache } from "./cache.js";
16
16
  import { Logger } from "./logger.js";
17
+ import { MigrationRegistry, CommandRegistry, PublishRegistry } from "./package.js";
17
18
  import { setApplication } from "./helpers.js";
18
19
  export class Application extends Container {
19
20
  basePath;
@@ -34,6 +35,11 @@ export class Application extends Container {
34
35
  this.singleton(View, () => new View());
35
36
  this.singleton(Events, () => new Events());
36
37
  this.singleton(Cache, () => new Cache());
38
+ // Package-system registries: what providers contribute for the console
39
+ // (`keel migrate`, `keel vendor:publish`) and the CLI to pick up after boot.
40
+ this.singleton(MigrationRegistry, () => new MigrationRegistry());
41
+ this.singleton(CommandRegistry, () => new CommandRegistry());
42
+ this.singleton(PublishRegistry, () => new PublishRegistry());
37
43
  this.singleton(Logger, (app) => new Logger({
38
44
  level: app.config().get("logger.level", "info"),
39
45
  pretty: Boolean(app.config().get("app.debug", false)),
@@ -95,8 +101,8 @@ export class Application extends Container {
95
101
  return this;
96
102
  }
97
103
  /** Emit an app event, awaiting every listener in registration order. */
98
- async emit(event, payload) {
99
- await this.make(Events).emit(event, payload);
104
+ async emit(event, ...args) {
105
+ await this.make(Events).emit(event, ...args);
100
106
  }
101
107
  /** Merge a config object into the repository under its top-level keys. */
102
108
  mergeConfig(data) {
@@ -5,6 +5,22 @@
5
5
  * the global `cache()` helper.
6
6
  *
7
7
  * const stats = await cache().remember("stats", 60, () => computeStats());
8
+ *
9
+ * The resilience features, kept inside keel's single-store, edge-safe model:
10
+ *
11
+ * - Stampede protection — concurrent `remember()` calls for the same cold key
12
+ * run the factory ONCE and share the result, instead of dog-piling.
13
+ * - Grace / stale-on-error — with a `grace` window, an expired value is kept
14
+ * a little longer and served if the refreshing factory throws.
15
+ * - Tags — group entries and invalidate them together with `deleteByTag()`.
16
+ * - Namespaces — `namespace("users")` scopes keys under a prefix, and its
17
+ * `flush()` clears only that namespace.
18
+ *
19
+ * Tag and namespace invalidation use version stamps, not a key index: each tag
20
+ * carries a counter, every entry records the counter values it was written at,
21
+ * and invalidation just bumps the counter — so an entry whose tag has moved on
22
+ * reads as a miss. O(1) invalidation, no key-set bookkeeping, works on any
23
+ * `CacheStore`.
8
24
  */
9
25
  export interface CacheStore {
10
26
  get(key: string): Promise<unknown> | unknown;
@@ -20,22 +36,82 @@ export declare class MemoryStore implements CacheStore {
20
36
  delete(key: string): void;
21
37
  clear(): void;
22
38
  }
39
+ /** Options for `put`/`add`. */
40
+ export interface PutOptions {
41
+ /** Tags to associate with this entry, for `deleteByTag` invalidation. */
42
+ tags?: string[];
43
+ }
44
+ /** Options for the `remember` family. */
45
+ export interface RememberOptions extends PutOptions {
46
+ /**
47
+ * Grace window in seconds. After the value's TTL lapses, keep it this much
48
+ * longer and serve it if the refreshing factory throws (stale-on-error).
49
+ */
50
+ grace?: number;
51
+ }
23
52
  export declare class Cache {
24
53
  private store;
25
54
  constructor(store?: CacheStore);
55
+ private prefix;
56
+ private nsTag?;
57
+ private inflight;
58
+ /** Apply this cache's namespace prefix to a logical key. */
59
+ private k;
60
+ private tagKey;
61
+ private tagVersion;
62
+ /** Snapshot the current version of each tag, to stamp onto a new entry. */
63
+ private stampTags;
64
+ /** True if any recorded tag version is behind the live one (invalidated). */
65
+ private tagsInvalidated;
66
+ /**
67
+ * Read the raw envelope for a key. Returns undefined when absent OR when a tag
68
+ * has invalidated it — tag/namespace invalidation is a hard miss (not grace-
69
+ * eligible). TTL freshness is left to callers, so grace can still see a stale
70
+ * entry.
71
+ */
72
+ private entry;
73
+ private write;
26
74
  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>;
75
+ /** Store a value, optionally expiring after `ttlSeconds`, with optional tags. */
76
+ put(key: string, value: unknown, ttlSeconds?: number, options?: PutOptions): Promise<void>;
77
+ /** Store a value only if the key is absent. Returns whether it was written. */
78
+ add(key: string, value: unknown, ttlSeconds?: number, options?: PutOptions): Promise<boolean>;
29
79
  has(key: string): Promise<boolean>;
80
+ /** The inverse of `has` — true when the key is absent or expired. */
81
+ missing(key: string): Promise<boolean>;
30
82
  forget(key: string): Promise<void>;
83
+ /** Forget several keys at once. */
84
+ forgetMany(keys: string[]): Promise<void>;
31
85
  /** Read and remove a value. */
32
86
  pull<T = unknown>(key: string, fallback?: T): Promise<T>;
33
87
  flush(): Promise<void>;
88
+ /**
89
+ * Invalidate every entry carrying any of these tags by bumping the tag's
90
+ * version — entries stamped with the old version then read as a miss. O(#tags),
91
+ * no key scan; invalidated entries fall out on their own TTL.
92
+ */
93
+ deleteByTag(tags: string[]): Promise<void>;
94
+ /**
95
+ * A cache scoped under a key prefix. Keys written through it live at
96
+ * `name:key`, and its `flush()` clears only this namespace (via an implicit
97
+ * tag) — the rest of the store is untouched. Namespaces nest.
98
+ *
99
+ * const users = cache().namespace("users");
100
+ * await users.put("1", user); // stored at "users:1"
101
+ * await users.flush(); // clears only the users namespace
102
+ */
103
+ namespace(name: string): Cache;
34
104
  /**
35
105
  * Return the cached value, or compute it with `factory`, cache it for
36
106
  * `ttlSeconds`, and return it.
107
+ *
108
+ * Concurrent calls for the same cold key share one factory run (stampede
109
+ * protection). With `{ grace }`, an expired value is retained that many extra
110
+ * seconds and served if the refreshing factory throws (stale-on-error). With
111
+ * `{ tags }`, the cached value joins those tags for `deleteByTag`.
37
112
  */
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>;
113
+ remember<T>(key: string, ttlSeconds: number, factory: () => T | Promise<T>, options?: RememberOptions): Promise<T>;
114
+ /** Like remember(), but cached forever (no TTL). Also stampede-protected. */
115
+ rememberForever<T>(key: string, factory: () => T | Promise<T>, options?: PutOptions): Promise<T>;
116
+ private resolve;
41
117
  }
@@ -5,7 +5,24 @@
5
5
  * the global `cache()` helper.
6
6
  *
7
7
  * const stats = await cache().remember("stats", 60, () => computeStats());
8
+ *
9
+ * The resilience features, kept inside keel's single-store, edge-safe model:
10
+ *
11
+ * - Stampede protection — concurrent `remember()` calls for the same cold key
12
+ * run the factory ONCE and share the result, instead of dog-piling.
13
+ * - Grace / stale-on-error — with a `grace` window, an expired value is kept
14
+ * a little longer and served if the refreshing factory throws.
15
+ * - Tags — group entries and invalidate them together with `deleteByTag()`.
16
+ * - Namespaces — `namespace("users")` scopes keys under a prefix, and its
17
+ * `flush()` clears only that namespace.
18
+ *
19
+ * Tag and namespace invalidation use version stamps, not a key index: each tag
20
+ * carries a counter, every entry records the counter values it was written at,
21
+ * and invalidation just bumps the counter — so an entry whose tag has moved on
22
+ * reads as a miss. O(1) invalidation, no key-set bookkeeping, works on any
23
+ * `CacheStore`.
8
24
  */
25
+ import { instrument, currentRequestId } from "./instrumentation.js";
9
26
  /** The default in-memory store. */
10
27
  export class MemoryStore {
11
28
  data = new Map();
@@ -29,24 +46,116 @@ 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
+ const hit = !!entry && !(entry.e && entry.e < Date.now());
120
+ const requestId = currentRequestId();
121
+ instrument(hit ? "cache.hit" : "cache.miss", {
122
+ key,
123
+ store: this.store.constructor.name,
124
+ ...(requestId ? { requestId } : {}),
125
+ });
126
+ if (!entry)
127
+ return fallback;
128
+ if (entry.e && entry.e < Date.now())
129
+ return fallback; // expired (grace-retained)
130
+ return entry.v;
131
+ }
132
+ /** Store a value, optionally expiring after `ttlSeconds`, with optional tags. */
133
+ async put(key, value, ttlSeconds, options) {
134
+ await this.write(key, value, ttlSeconds, 0, options?.tags ?? []);
40
135
  }
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);
136
+ /** Store a value only if the key is absent. Returns whether it was written. */
137
+ async add(key, value, ttlSeconds, options) {
138
+ if (await this.has(key))
139
+ return false;
140
+ await this.put(key, value, ttlSeconds, options);
141
+ return true;
44
142
  }
45
143
  async has(key) {
46
- return (await this.store.get(key)) !== undefined;
144
+ const entry = await this.entry(key);
145
+ if (!entry)
146
+ return false;
147
+ return !(entry.e && entry.e < Date.now());
148
+ }
149
+ /** The inverse of `has` — true when the key is absent or expired. */
150
+ async missing(key) {
151
+ return !(await this.has(key));
47
152
  }
48
153
  async forget(key) {
49
- await this.store.delete(key);
154
+ await this.store.delete(this.k(key));
155
+ }
156
+ /** Forget several keys at once. */
157
+ async forgetMany(keys) {
158
+ await Promise.all(keys.map((k) => this.store.delete(this.k(k))));
50
159
  }
51
160
  /** Read and remove a value. */
52
161
  async pull(key, fallback) {
@@ -55,27 +164,83 @@ export class Cache {
55
164
  return value;
56
165
  }
57
166
  async flush() {
58
- await this.store.clear();
167
+ // A namespace clears itself by moving its tag version on; the root wipes the
168
+ // whole store.
169
+ if (this.nsTag)
170
+ await this.deleteByTag([this.nsTag]);
171
+ else
172
+ await this.store.clear();
173
+ }
174
+ /**
175
+ * Invalidate every entry carrying any of these tags by bumping the tag's
176
+ * version — entries stamped with the old version then read as a miss. O(#tags),
177
+ * no key scan; invalidated entries fall out on their own TTL.
178
+ */
179
+ async deleteByTag(tags) {
180
+ for (const tag of tags) {
181
+ const current = await this.tagVersion(tag);
182
+ await this.store.set(this.tagKey(tag), current + 1); // no TTL — must persist
183
+ }
184
+ }
185
+ /**
186
+ * A cache scoped under a key prefix. Keys written through it live at
187
+ * `name:key`, and its `flush()` clears only this namespace (via an implicit
188
+ * tag) — the rest of the store is untouched. Namespaces nest.
189
+ *
190
+ * const users = cache().namespace("users");
191
+ * await users.put("1", user); // stored at "users:1"
192
+ * await users.flush(); // clears only the users namespace
193
+ */
194
+ namespace(name) {
195
+ const child = new Cache(this.store);
196
+ child.prefix = `${this.prefix}${name}:`;
197
+ child.nsTag = `\u0000keel:ns:${this.prefix}${name}`;
198
+ return child;
59
199
  }
60
200
  /**
61
201
  * Return the cached value, or compute it with `factory`, cache it for
62
202
  * `ttlSeconds`, and return it.
203
+ *
204
+ * Concurrent calls for the same cold key share one factory run (stampede
205
+ * protection). With `{ grace }`, an expired value is retained that many extra
206
+ * seconds and served if the refreshing factory throws (stale-on-error). With
207
+ * `{ tags }`, the cached value joins those tags for `deleteByTag`.
63
208
  */
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;
209
+ remember(key, ttlSeconds, factory, options) {
210
+ return this.resolve(key, ttlSeconds, factory, options?.grace ?? 0, options?.tags ?? []);
71
211
  }
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;
212
+ /** Like remember(), but cached forever (no TTL). Also stampede-protected. */
213
+ rememberForever(key, factory, options) {
214
+ return this.resolve(key, undefined, factory, 0, options?.tags ?? []);
215
+ }
216
+ async resolve(key, ttlSeconds, factory, graceSeconds, tags) {
217
+ const entry = await this.entry(key);
218
+ const fresh = entry && (!entry.e || entry.e > Date.now());
219
+ if (fresh)
220
+ return entry.v;
221
+ // Stampede protection: join an in-flight computation for this key.
222
+ const pending = this.inflight.get(key);
223
+ if (pending)
224
+ return pending;
225
+ const run = (async () => {
226
+ try {
227
+ // Defer to a microtask so a synchronously-throwing factory rejects the
228
+ // shared promise only after every joiner has attached its handler.
229
+ const value = await Promise.resolve().then(factory);
230
+ await this.write(key, value, ttlSeconds, graceSeconds, tags);
231
+ return value;
232
+ }
233
+ catch (err) {
234
+ // Grace: a stale-but-retained value rescues a failing refresh.
235
+ if (entry && graceSeconds > 0)
236
+ return entry.v;
237
+ throw err;
238
+ }
239
+ finally {
240
+ this.inflight.delete(key);
241
+ }
242
+ })();
243
+ this.inflight.set(key, run);
244
+ return run;
80
245
  }
81
246
  }
@@ -0,0 +1,26 @@
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
+ /**
12
+ * A package skeleton: a `PackageProvider` wired for the common jobs — merging
13
+ * config, contributing a migration, mounting routes + assets, and declaring a
14
+ * publishable config stub. `name` is the short package name (e.g. "billing").
15
+ */
16
+ export declare function packageProviderStub(name: string): string;
17
+ /** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
18
+ export declare function transformerStub(name: string, model: string): string;
19
+ /**
20
+ * A page — `resources/pages/<path>.tsx`. The file's location *is* its URL, so the
21
+ * stub is deliberately bare: a component, and a loader only when the path takes a
22
+ * parameter to load anything by.
23
+ */
24
+ export declare function pageStub(file: string): string;
25
+ /** A console command — `app/Commands/<name>.ts`. */
26
+ export declare function commandStub(name: string): string;