@shaferllc/keel 0.68.0 → 0.77.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 (143) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +15 -5
  3. package/dist/api/api.config.stub +9 -0
  4. package/dist/api/config.d.ts +13 -0
  5. package/dist/api/config.js +14 -0
  6. package/dist/api/index.d.ts +16 -0
  7. package/dist/api/index.js +13 -0
  8. package/dist/api/provider.d.ts +10 -0
  9. package/dist/api/provider.js +17 -0
  10. package/dist/api/query.d.ts +35 -0
  11. package/dist/api/query.js +42 -0
  12. package/dist/api/resource.d.ts +93 -0
  13. package/dist/api/resource.js +185 -0
  14. package/dist/core/application.js +6 -0
  15. package/dist/core/cache.d.ts +1 -2
  16. package/dist/core/cache.js +9 -2
  17. package/dist/core/cli/index.d.ts +28 -0
  18. package/dist/core/cli/index.js +427 -0
  19. package/dist/core/cli/stubs.d.ts +14 -0
  20. package/dist/core/cli/stubs.js +105 -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/health.d.ts +2 -2
  32. package/dist/core/health.js +2 -2
  33. package/dist/core/http/kernel.d.ts +2 -0
  34. package/dist/core/http/kernel.js +48 -0
  35. package/dist/core/http/router.d.ts +5 -5
  36. package/dist/core/http/router.js +5 -5
  37. package/dist/core/i18n.d.ts +162 -0
  38. package/dist/core/i18n.js +472 -0
  39. package/dist/core/index.d.ts +25 -4
  40. package/dist/core/index.js +13 -3
  41. package/dist/core/instrumentation.d.ts +113 -0
  42. package/dist/core/instrumentation.js +52 -0
  43. package/dist/core/logger.d.ts +7 -0
  44. package/dist/core/logger.js +28 -1
  45. package/dist/core/notification.js +10 -1
  46. package/dist/core/package.d.ts +117 -0
  47. package/dist/core/package.js +169 -0
  48. package/dist/core/pages.d.ts +108 -0
  49. package/dist/core/pages.js +199 -0
  50. package/dist/core/queue.js +26 -5
  51. package/dist/core/repl.d.ts +33 -0
  52. package/dist/core/repl.js +88 -0
  53. package/dist/core/scheduler.js +6 -0
  54. package/dist/core/social.d.ts +4 -4
  55. package/dist/core/social.js +4 -4
  56. package/dist/core/storage.js +15 -3
  57. package/dist/core/telemetry.d.ts +208 -0
  58. package/dist/core/telemetry.js +383 -0
  59. package/dist/core/template.d.ts +2 -3
  60. package/dist/core/template.js +2 -3
  61. package/dist/core/testing.d.ts +171 -1
  62. package/dist/core/testing.js +505 -2
  63. package/dist/db/d1.js +13 -0
  64. package/dist/db/pg.d.ts +13 -0
  65. package/dist/db/pg.js +46 -4
  66. package/dist/openapi/config.d.ts +28 -0
  67. package/dist/openapi/config.js +25 -0
  68. package/dist/openapi/doc.d.ts +40 -0
  69. package/dist/openapi/doc.js +20 -0
  70. package/dist/openapi/export.d.ts +8 -0
  71. package/dist/openapi/export.js +19 -0
  72. package/dist/openapi/gate.d.ts +15 -0
  73. package/dist/openapi/gate.js +27 -0
  74. package/dist/openapi/index.d.ts +19 -0
  75. package/dist/openapi/index.js +15 -0
  76. package/dist/openapi/openapi.config.stub +29 -0
  77. package/dist/openapi/provider.d.ts +18 -0
  78. package/dist/openapi/provider.js +35 -0
  79. package/dist/openapi/routes.d.ts +9 -0
  80. package/dist/openapi/routes.js +23 -0
  81. package/dist/openapi/spec.d.ts +23 -0
  82. package/dist/openapi/spec.js +132 -0
  83. package/dist/openapi/ui.d.ts +8 -0
  84. package/dist/openapi/ui.js +31 -0
  85. package/dist/openapi/zod.d.ts +12 -0
  86. package/dist/openapi/zod.js +46 -0
  87. package/dist/watch/config.d.ts +33 -0
  88. package/dist/watch/config.js +38 -0
  89. package/dist/watch/entry.d.ts +53 -0
  90. package/dist/watch/entry.js +105 -0
  91. package/dist/watch/gate.d.ts +20 -0
  92. package/dist/watch/gate.js +32 -0
  93. package/dist/watch/index.d.ts +21 -0
  94. package/dist/watch/index.js +17 -0
  95. package/dist/watch/migration.d.ts +7 -0
  96. package/dist/watch/migration.js +20 -0
  97. package/dist/watch/provider.d.ts +22 -0
  98. package/dist/watch/provider.js +58 -0
  99. package/dist/watch/prune.d.ts +11 -0
  100. package/dist/watch/prune.js +22 -0
  101. package/dist/watch/recorder.d.ts +24 -0
  102. package/dist/watch/recorder.js +39 -0
  103. package/dist/watch/routes.d.ts +13 -0
  104. package/dist/watch/routes.js +55 -0
  105. package/dist/watch/store.d.ts +54 -0
  106. package/dist/watch/store.js +158 -0
  107. package/dist/watch/ui/dist/watch.css +1 -0
  108. package/dist/watch/ui/dist/watch.js +555 -0
  109. package/dist/watch/ui-shell.d.ts +12 -0
  110. package/dist/watch/ui-shell.js +24 -0
  111. package/dist/watch/watch.config.stub +47 -0
  112. package/dist/watch/watchers.d.ts +12 -0
  113. package/dist/watch/watchers.js +156 -0
  114. package/docs/ai-manifest.json +737 -3
  115. package/docs/api-resources.md +119 -0
  116. package/docs/configuration.md +74 -0
  117. package/docs/console.md +217 -13
  118. package/docs/database.md +101 -0
  119. package/docs/examples/api-resources.ts +86 -0
  120. package/docs/examples/configuration.ts +40 -0
  121. package/docs/examples/console.ts +134 -0
  122. package/docs/examples/database.ts +90 -0
  123. package/docs/examples/i18n.ts +117 -0
  124. package/docs/examples/logger.ts +74 -0
  125. package/docs/examples/mail.ts +103 -0
  126. package/docs/examples/pages.ts +82 -0
  127. package/docs/examples/telemetry.ts +127 -0
  128. package/docs/examples/testing.ts +150 -0
  129. package/docs/health.md +4 -4
  130. package/docs/i18n.md +302 -0
  131. package/docs/logger.md +156 -10
  132. package/docs/mail.md +73 -0
  133. package/docs/openapi.md +111 -0
  134. package/docs/packages.md +118 -0
  135. package/docs/pages.md +217 -0
  136. package/docs/storage.md +5 -3
  137. package/docs/telemetry.md +263 -0
  138. package/docs/templates.md +3 -4
  139. package/docs/testing.md +252 -0
  140. package/docs/watch.md +118 -0
  141. package/llms-full.txt +2389 -241
  142. package/llms.txt +13 -1
  143. package/package.json +32 -6
package/AGENTS.md CHANGED
@@ -89,6 +89,7 @@ right imports and target path. Then wire it up.
89
89
  | Job | `keel make:job SendWelcome` | `dispatch(new SendWelcomeJob())` |
90
90
  | Notification | `keel make:notification Paid` | `notify(user, new PaidNotification())` |
91
91
  | Transformer | `keel make:transformer User` | return it from a controller |
92
+ | Package | `keel make:package billing` | a `PackageProvider` — see [`docs/packages.md`](./docs/packages.md) |
92
93
 
93
94
  **Scaffolding does not write files** via MCP — it returns code + path; you write
94
95
  it. The console (`keel make:*`) does write, and refuses to overwrite.
@@ -112,6 +113,7 @@ router.get("/posts", [PostController, "index"]).name("posts.index");
112
113
  npm run dev # example app on http://localhost:3000 (tsx watch)
113
114
  npm run serve # keel serve
114
115
  npm run keel -- routes # list registered routes
116
+ npm run keel -- migrate # run pending app + package migrations (also migrate:status, migrate:rollback)
115
117
  npm run keel -- make:controller Foo
116
118
  npm run mcp # start the MCP server over stdio (dev)
117
119
  npm test # node --test over tests/*.test.ts
package/README.md CHANGED
@@ -108,6 +108,9 @@ 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 make:page users/[id] # -> resources/pages/users/[id].tsx
112
+ npm run keel make:command greet # -> app/Commands/greet.ts
113
+ npm run keel repl # a shell with the app booted
111
114
  npm run keel mcp # start the MCP server (docs + API for AI agents)
112
115
  ```
113
116
 
@@ -206,31 +209,38 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
206
209
  | [Models](./docs/models.md) | Active-record: find/create/save, casts, relations |
207
210
  | [Migrations](./docs/migrations.md) | Schema builder + migrator, dialect-aware |
208
211
  | [Factories & Seeders](./docs/factories.md) | Built-in Faker, model factories, seeders |
209
- | [Mail](./docs/mail.md) | Fluent mailer, pluggable transports, edge-safe |
210
- | [Queues & Jobs](./docs/queues.md) | Dispatch jobs, pluggable drivers, workers |
212
+ | [Mail](./docs/mail.md) | Fluent mailer, pluggable transports, `sendLater()`, attachments |
213
+ | [Queues & Jobs](./docs/queues.md) | Dispatch jobs, retries + backoff, dead-letter, workers |
211
214
  | [Task Scheduling](./docs/scheduling.md) | Cron-style recurring tasks, one trigger |
212
215
  | [Notifications](./docs/notifications.md) | Multi-channel (mail/db), queueable |
213
216
  | [Broadcasting](./docs/broadcasting.md) | Real-time channels, pluggable, presence auth |
217
+ | [API Resources](./docs/api-resources.md) | CRUD REST API from a model; deny-by-default access, row-level scope |
214
218
  | [Transformers](./docs/transformers.md) | Shape models into API JSON; conditional fields, relations |
215
219
  | [Events](./docs/events.md) | Emit/listen decoupling, async listeners |
216
220
  | [Service Broker](./docs/broker.md) | Moleculer-style services, call/emit, pluggable transport |
217
221
  | [Cache](./docs/cache.md) | TTLs, the remember pattern, pluggable stores |
222
+ | [Locks](./docs/locks.md) | Distributed locks with ownership + TTL, pluggable stores |
218
223
  | [Redis](./docs/redis.md) | Pluggable client, memory driver, cache adapter |
219
- | [Logger](./docs/logger.md) | Structured logging, per-request `reqId`, redaction |
224
+ | [Logger](./docs/logger.md) | Structured logging, sinks, per-request `reqId`, redaction |
220
225
  | [Static Files](./docs/static-files.md) | serveStatic(), caching, dot-file safety |
221
226
  | [Storage](./docs/storage.md) | Pluggable disks (local/S3/R2), signed URLs, direct uploads |
222
227
  | [Health Checks](./docs/health.md) | `/health/live` + `/health/ready`, pluggable checks |
228
+ | [Telemetry](./docs/telemetry.md) | Tracing, W3C context, OTLP export — no SDK |
229
+ | [Internationalization](./docs/i18n.md) | ICU messages, `Intl` formatters, locale detection |
230
+ | [Pages](./docs/pages.md) | Page-based routing — a file is a route |
231
+ | [Packages](./docs/packages.md) | Redistributable slices of an app: routes, migrations, commands |
232
+ | [Watch](./docs/watch.md) | Debug dashboard — requests, queries, jobs, logs at `/watch` |
223
233
  | [Views](./docs/views.md) | Hono JSX components, layouts, the View service |
224
234
  | [Templates](./docs/templates.md) | `{{ }}` + `@`-tag templating engine, edge-safe |
225
235
  | [Middleware](./docs/middleware.md) | Global middleware, writing your own |
226
236
  | [Rate Limiting](./docs/rate-limiting.md) | rateLimiter() middleware, per-key buckets |
227
237
  | [Errors](./docs/errors.md) | HTTP exceptions, debug page, custom handlers |
228
- | [Testing](./docs/testing.md) | Inject requests, fluent response assertions |
238
+ | [Testing](./docs/testing.md) | Inject requests, fakes, spies, time travel, db assertions |
229
239
  | [Debugging](./docs/debugging.md) | dump() and dd() (dump-and-die) |
230
240
  | [Validation](./docs/validation.md) | `validate()` with Zod, auto-422 field errors |
231
241
  | [Inertia](./docs/inertia.md) | Server-side Inertia.js adapter |
232
242
  | [Vite](./docs/vite.md) | Frontend build: HMR in dev, hashed manifest in prod |
233
- | [The Console](./docs/console.md) | `serve`, `routes`, `make:*` generators |
243
+ | [The Console](./docs/console.md) | Typed commands, prompts, terminal UI, REPL, `make:*` |
234
244
  | [Architecture](./docs/architecture.md) | Container, kernel, request lifecycle |
235
245
  | [Built on Hono](./docs/hono.md) | The Hono layer underneath, and using it directly |
236
246
  | [Building with AI](./docs/ai.md) | MCP server, `AGENTS.md`, `llms.txt`, agent workflow |
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Keel API-resource defaults.
3
+ * Published with `keel vendor:publish --tag api-config`.
4
+ */
5
+ export default {
6
+ // Default list page size, and the hard ceiling on ?perPage=.
7
+ perPage: 25,
8
+ maxPerPage: 100,
9
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * API-resource defaults. Read off `config("api")`, so an app can set global
3
+ * pagination limits in `config/api.ts`; every `apiResource()` inherits them and
4
+ * can override per-resource.
5
+ */
6
+ export interface ApiConfig {
7
+ /** Default page size for list endpoints. */
8
+ perPage: number;
9
+ /** Hard ceiling on `?perPage=` — the guard against "give me everything". */
10
+ maxPerPage: number;
11
+ }
12
+ export declare const defaultConfig: ApiConfig;
13
+ export declare function apiDefaults(): ApiConfig;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * API-resource defaults. Read off `config("api")`, so an app can set global
3
+ * pagination limits in `config/api.ts`; every `apiResource()` inherits them and
4
+ * can override per-resource.
5
+ */
6
+ import { config } from "../core/helpers.js";
7
+ export const defaultConfig = {
8
+ perPage: 25,
9
+ maxPerPage: 100,
10
+ };
11
+ export function apiDefaults() {
12
+ const raw = config("api", {});
13
+ return { ...defaultConfig, ...raw };
14
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Keel API resources — a full CRUD REST API from a model, imported from
3
+ * `@shaferllc/keel/api`.
4
+ *
5
+ * import { apiResource } from "@shaferllc/keel/api";
6
+ * apiResource(router, Post, { filter: ["status"], body: PostSchema, access: { read: true } });
7
+ *
8
+ * The generated routes are documented automatically by `@shaferllc/keel/openapi`.
9
+ */
10
+ export { apiResource } from "./resource.js";
11
+ export type { ApiResourceOptions, ApiAccess, Access, ApiAction, ApiTransform, ModelStatic, } from "./resource.js";
12
+ export { ApiServiceProvider } from "./provider.js";
13
+ export { defaultConfig, apiDefaults } from "./config.js";
14
+ export type { ApiConfig } from "./config.js";
15
+ export { parseListParams, applyListParams } from "./query.js";
16
+ export type { ListParams, ListQueryOptions } from "./query.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Keel API resources — a full CRUD REST API from a model, imported from
3
+ * `@shaferllc/keel/api`.
4
+ *
5
+ * import { apiResource } from "@shaferllc/keel/api";
6
+ * apiResource(router, Post, { filter: ["status"], body: PostSchema, access: { read: true } });
7
+ *
8
+ * The generated routes are documented automatically by `@shaferllc/keel/openapi`.
9
+ */
10
+ export { apiResource } from "./resource.js";
11
+ export { ApiServiceProvider } from "./provider.js";
12
+ export { defaultConfig, apiDefaults } from "./config.js";
13
+ export { parseListParams, applyListParams } from "./query.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Optional provider for the API-resource layer. `apiResource()` works without it —
3
+ * it just reads sensible defaults. Register this to override the pagination
4
+ * defaults in `config/api.ts` and to publish that stub.
5
+ */
6
+ import { PackageProvider } from "../core/package.js";
7
+ export declare class ApiServiceProvider extends PackageProvider {
8
+ readonly name = "api";
9
+ register(): void;
10
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Optional provider for the API-resource layer. `apiResource()` works without it —
3
+ * it just reads sensible defaults. Register this to override the pagination
4
+ * defaults in `config/api.ts` and to publish that stub.
5
+ */
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+ import { PackageProvider } from "../core/package.js";
9
+ import { defaultConfig } from "./config.js";
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ export class ApiServiceProvider extends PackageProvider {
12
+ name = "api";
13
+ register() {
14
+ this.mergeConfig("api", defaultConfig);
15
+ this.publishes({ [join(here, "api.config.stub")]: "config/api.ts" }, "api-config");
16
+ }
17
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Turn the query string of a list request into filters, sorting, and a page —
3
+ * safely. The cardinal rule of an auto-generated API: **never** let the client
4
+ * filter or sort by an arbitrary column. Everything here is checked against an
5
+ * allow-list; anything not on it is silently ignored, not passed to SQL. That's
6
+ * what keeps `?password=…` or `?sort=secret_column` from doing anything.
7
+ */
8
+ import type { Ctx } from "../core/http/router.js";
9
+ import type { QueryBuilder } from "../core/database.js";
10
+ export interface ListQueryOptions {
11
+ /** Columns a client may filter on (`?status=published`). */
12
+ filter: string[];
13
+ /** Columns a client may sort by (`?sort=title,-createdAt`). */
14
+ sort: string[];
15
+ /** Default page size. */
16
+ perPage: number;
17
+ /** Maximum `?perPage=`. */
18
+ maxPerPage: number;
19
+ }
20
+ export interface ListParams {
21
+ page: number;
22
+ perPage: number;
23
+ filters: Array<{
24
+ column: string;
25
+ value: string;
26
+ }>;
27
+ sort: Array<{
28
+ column: string;
29
+ direction: "asc" | "desc";
30
+ }>;
31
+ }
32
+ /** Parse (and allow-list) the list query params. */
33
+ export declare function parseListParams(c: Ctx, opts: ListQueryOptions): ListParams;
34
+ /** Apply parsed filters and sorting to a query builder. */
35
+ export declare function applyListParams(query: QueryBuilder, params: ListParams): QueryBuilder;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Turn the query string of a list request into filters, sorting, and a page —
3
+ * safely. The cardinal rule of an auto-generated API: **never** let the client
4
+ * filter or sort by an arbitrary column. Everything here is checked against an
5
+ * allow-list; anything not on it is silently ignored, not passed to SQL. That's
6
+ * what keeps `?password=…` or `?sort=secret_column` from doing anything.
7
+ */
8
+ /** Query keys that control pagination/sorting, never treated as filters. */
9
+ const RESERVED = new Set(["page", "perPage", "per_page", "sort", "q", "limit", "offset"]);
10
+ function toInt(value, fallback) {
11
+ const n = Number(value);
12
+ return Number.isFinite(n) ? Math.trunc(n) : fallback;
13
+ }
14
+ /** Parse (and allow-list) the list query params. */
15
+ export function parseListParams(c, opts) {
16
+ const q = c.req.query();
17
+ const page = Math.max(1, toInt(q.page, 1));
18
+ const perPage = Math.min(Math.max(1, toInt(q.perPage ?? q.per_page, opts.perPage)), opts.maxPerPage);
19
+ const allowFilter = new Set(opts.filter);
20
+ const filters = Object.entries(q)
21
+ .filter(([key]) => !RESERVED.has(key) && allowFilter.has(key))
22
+ .map(([column, value]) => ({ column, value }));
23
+ const allowSort = new Set(opts.sort);
24
+ const sort = (q.sort ?? "")
25
+ .split(",")
26
+ .map((s) => s.trim())
27
+ .filter(Boolean)
28
+ .map((s) => s.startsWith("-")
29
+ ? { column: s.slice(1), direction: "desc" }
30
+ : { column: s, direction: "asc" })
31
+ .filter((s) => allowSort.has(s.column));
32
+ return { page, perPage, filters, sort };
33
+ }
34
+ /** Apply parsed filters and sorting to a query builder. */
35
+ export function applyListParams(query, params) {
36
+ let q = query;
37
+ for (const f of params.filters)
38
+ q = q.where(f.column, f.value);
39
+ for (const s of params.sort)
40
+ q = q.orderBy(s.column, s.direction);
41
+ return q;
42
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `apiResource(router, Model, options)` — a full CRUD REST API from a Keel model:
3
+ * explicit, server-side, and composed from pieces you already have. It registers
4
+ * 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 type { Router, Ctx } from "../core/http/router.js";
21
+ import type { QueryBuilder, Row } from "../core/database.js";
22
+ import type { Model } from "../core/model.js";
23
+ import type { Schema } from "../core/validation.js";
24
+ /** A Model subclass (the class itself, with its statics). */
25
+ export type ModelStatic = typeof Model;
26
+ /** A CRUD action. */
27
+ export type ApiAction = "list" | "read" | "create" | "update" | "delete";
28
+ /** An access rule: a flat allow/deny, or a per-request predicate. */
29
+ export type Access = boolean | ((c: Ctx) => boolean | Promise<boolean>);
30
+ /**
31
+ * Access rules per action, with shorthands. Resolution for an action prefers its
32
+ * own key, then the read/write shorthand, then `all`; absent → denied.
33
+ */
34
+ export interface ApiAccess {
35
+ /** Fallback for every action. */
36
+ all?: Access;
37
+ /** Shorthand for `list` + `get`. */
38
+ read?: Access;
39
+ /** Shorthand for `create` + `update` + `delete`. */
40
+ write?: Access;
41
+ list?: Access;
42
+ /** The single-item read (GET /:id). */
43
+ get?: Access;
44
+ create?: Access;
45
+ update?: Access;
46
+ delete?: Access;
47
+ }
48
+ /** How a model is turned into API output — a function, or a Keel Transformer. */
49
+ export type ApiTransform = ((model: Model, c: Ctx) => unknown) | {
50
+ item(value: Model): unknown;
51
+ collection(values: Model[]): unknown[];
52
+ };
53
+ export interface ApiResourceOptions {
54
+ /** Base path. Default: the model's table name. */
55
+ path?: string;
56
+ /** Route-name prefix. Default: the path. */
57
+ name?: string;
58
+ /** Only expose these actions. */
59
+ only?: ApiAction[];
60
+ /** Expose everything except these. */
61
+ except?: ApiAction[];
62
+ /** Columns clients may filter on. Empty = no filtering. */
63
+ filter?: string[];
64
+ /** Columns clients may sort by. Empty = no sorting. */
65
+ sort?: string[];
66
+ /** Default page size (overrides `config("api.perPage")`). */
67
+ perPage?: number;
68
+ /** Max page size (overrides `config("api.maxPerPage")`). */
69
+ maxPerPage?: number;
70
+ /** Zod (or Zod-like) schema validating create + update bodies. */
71
+ body?: Schema<unknown>;
72
+ /** Schema for create only (overrides `body`). */
73
+ createBody?: Schema<unknown>;
74
+ /** Schema for update only (overrides `body`). */
75
+ updateBody?: Schema<unknown>;
76
+ /** Access rules. Deny by default. */
77
+ access?: ApiAccess;
78
+ /**
79
+ * Constrain the base query for **every** row operation — list, read, update,
80
+ * delete. This is row-level security: a row outside the scope reads as 404, so
81
+ * it can't be fetched, changed, or removed.
82
+ */
83
+ scope?: (query: QueryBuilder, c: Ctx) => QueryBuilder | void;
84
+ /** Shape the output. Default: `model.toJSON()`. */
85
+ transform?: ApiTransform;
86
+ /** Mutate the write payload before it's saved (set an owner id, timestamps…). */
87
+ beforeWrite?: (data: Row, c: Ctx, action: "create" | "update") => Row | Promise<Row>;
88
+ /** OpenAPI tags for these routes. Default: `[path]`. */
89
+ tags?: string[];
90
+ /** Singular display name used in generated doc summaries. Default: the model's class name. */
91
+ label?: string;
92
+ }
93
+ export declare function apiResource(router: Router, model: ModelStatic, options?: ApiResourceOptions): void;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `apiResource(router, Model, options)` — a full CRUD REST API from a Keel model:
3
+ * explicit, server-side, and composed from pieces you already have. It registers
4
+ * 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
+ export function apiResource(router, model, options = {}) {
76
+ const defaults = apiDefaults();
77
+ const path = options.path ?? model.table;
78
+ const base = "/" + path.replace(/^\/|\/$/g, "");
79
+ const name = options.name ?? path;
80
+ const pk = model.primaryKey;
81
+ const label = options.label ?? model.name;
82
+ const tags = options.tags ?? [path];
83
+ const perPage = options.perPage ?? defaults.perPage;
84
+ const maxPerPage = options.maxPerPage ?? defaults.maxPerPage;
85
+ const actions = resolveActions(options);
86
+ /** The base query with any row-level scope applied. */
87
+ const scoped = (c) => {
88
+ const q = model.query();
89
+ if (!options.scope)
90
+ return q;
91
+ return options.scope(q, c) ?? q;
92
+ };
93
+ /** Find one row within scope, or 404. */
94
+ const findScoped = async (c) => {
95
+ const row = await scoped(c).where(pk, c.req.param("id")).first();
96
+ if (!row)
97
+ throw new NotFoundException(`${model.name} not found`);
98
+ return new model(row);
99
+ };
100
+ const readBody = async (c, schema) => {
101
+ const raw = (await c.req.json().catch(() => ({})));
102
+ return schema ? (await validate(schema, raw)) : raw;
103
+ };
104
+ if (actions.has("list")) {
105
+ router
106
+ .get(base, async (c) => {
107
+ await authorize(options.access, "list", c);
108
+ const params = parseListParams(c, {
109
+ filter: options.filter ?? [],
110
+ sort: options.sort ?? [],
111
+ perPage,
112
+ maxPerPage,
113
+ });
114
+ const query = applyListParams(scoped(c), params);
115
+ const result = await query.paginate(params.page, params.perPage);
116
+ const models = result.data.map((row) => new model(row));
117
+ return c.json({
118
+ data: transformMany(options.transform, models, c),
119
+ meta: {
120
+ total: result.total,
121
+ perPage: result.perPage,
122
+ currentPage: result.currentPage,
123
+ lastPage: result.lastPage,
124
+ },
125
+ });
126
+ })
127
+ .name(`${name}.list`)
128
+ .config(openApiConfig({ summary: `List ${path}`, tags }));
129
+ }
130
+ if (actions.has("read")) {
131
+ router
132
+ .get(`${base}/:id`, async (c) => {
133
+ await authorize(options.access, "read", c);
134
+ const found = await findScoped(c);
135
+ return c.json({ data: transformOne(options.transform, found, c) });
136
+ })
137
+ .name(`${name}.read`)
138
+ .config(openApiConfig({ summary: `Fetch ${label}`, tags }));
139
+ }
140
+ if (actions.has("create")) {
141
+ const schema = options.createBody ?? options.body;
142
+ router
143
+ .post(base, async (c) => {
144
+ await authorize(options.access, "create", c);
145
+ let data = await readBody(c, schema);
146
+ if (options.beforeWrite)
147
+ data = await options.beforeWrite(data, c, "create");
148
+ const created = await model.create(data);
149
+ return c.json({ data: transformOne(options.transform, created, c) }, 201);
150
+ })
151
+ .name(`${name}.create`)
152
+ .config(openApiConfig({
153
+ summary: `Create ${label}`,
154
+ tags,
155
+ ...(schema ? { request: { body: schema } } : {}),
156
+ responses: { 201: { description: `The created ${label}` } },
157
+ }));
158
+ }
159
+ if (actions.has("update")) {
160
+ const schema = options.updateBody ?? options.body;
161
+ router
162
+ .route(["PUT", "PATCH"], `${base}/:id`, async (c) => {
163
+ await authorize(options.access, "update", c);
164
+ const found = await findScoped(c);
165
+ let data = await readBody(c, schema);
166
+ if (options.beforeWrite)
167
+ data = await options.beforeWrite(data, c, "update");
168
+ await found.update(data);
169
+ return c.json({ data: transformOne(options.transform, found, c) });
170
+ })
171
+ .name(`${name}.update`)
172
+ .config(openApiConfig({ summary: `Update ${label}`, tags, ...(schema ? { request: { body: schema } } : {}) }));
173
+ }
174
+ if (actions.has("delete")) {
175
+ router
176
+ .delete(`${base}/:id`, async (c) => {
177
+ await authorize(options.access, "delete", c);
178
+ const found = await findScoped(c);
179
+ await found.delete();
180
+ return c.body(null, 204);
181
+ })
182
+ .name(`${name}.delete`)
183
+ .config(openApiConfig({ summary: `Delete ${label}`, tags, responses: { 204: { description: "Deleted" } } }));
184
+ }
185
+ }
@@ -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)),
@@ -6,8 +6,7 @@
6
6
  *
7
7
  * const stats = await cache().remember("stats", 60, () => computeStats());
8
8
  *
9
- * Features borrowed from bentocache (AdonisJS' cache), kept inside keel's
10
- * single-store, edge-safe model:
9
+ * The resilience features, kept inside keel's single-store, edge-safe model:
11
10
  *
12
11
  * - Stampede protection — concurrent `remember()` calls for the same cold key
13
12
  * run the factory ONCE and share the result, instead of dog-piling.
@@ -6,8 +6,7 @@
6
6
  *
7
7
  * const stats = await cache().remember("stats", 60, () => computeStats());
8
8
  *
9
- * Features borrowed from bentocache (AdonisJS' cache), kept inside keel's
10
- * single-store, edge-safe model:
9
+ * The resilience features, kept inside keel's single-store, edge-safe model:
11
10
  *
12
11
  * - Stampede protection — concurrent `remember()` calls for the same cold key
13
12
  * run the factory ONCE and share the result, instead of dog-piling.
@@ -23,6 +22,7 @@
23
22
  * reads as a miss. O(1) invalidation, no key-set bookkeeping, works on any
24
23
  * `CacheStore`.
25
24
  */
25
+ import { instrument, currentRequestId } from "./instrumentation.js";
26
26
  /** The default in-memory store. */
27
27
  export class MemoryStore {
28
28
  data = new Map();
@@ -116,6 +116,13 @@ export class Cache {
116
116
  }
117
117
  async get(key, fallback) {
118
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
+ });
119
126
  if (!entry)
120
127
  return fallback;
121
128
  if (entry.e && entry.e < Date.now())
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The Keel console. `keel <command>`.
3
+ *
4
+ * Commands:
5
+ * keel serve start the HTTP server
6
+ * keel make:controller Foo generate app/Controllers/FooController.ts
7
+ * keel make:provider Foo generate app/Providers/FooServiceProvider.ts
8
+ * keel make:middleware Foo generate app/Http/Middleware/foo.ts
9
+ * keel make:factory User generate database/factories/UserFactory.ts
10
+ * keel make:seeder Database generate database/seeders/DatabaseSeeder.ts
11
+ * keel make:job SendWelcome generate app/Jobs/SendWelcomeJob.ts
12
+ * keel make:notification Paid generate app/Notifications/PaidNotification.ts
13
+ * keel make:transformer User generate app/Transformers/UserTransformer.ts
14
+ * keel routes list registered routes
15
+ * keel mcp start the MCP server (docs + API for AI agents)
16
+ */
17
+ import type { Application } from "../application.js";
18
+ /** What the console needs from your app: a way to build it. */
19
+ export interface ConsoleOptions {
20
+ /**
21
+ * Build and boot the application. The console is handed this rather than
22
+ * importing it, because a framework that imports an *application* has its
23
+ * dependency pointing the wrong way — and that one import is what kept this
24
+ * file out of the published build for so long.
25
+ */
26
+ createApplication: () => Promise<Application>;
27
+ }
28
+ export declare function run(argv: string[], options: ConsoleOptions): Promise<void>;