@shaferllc/keel 0.68.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.
- package/AGENTS.md +2 -0
- package/README.md +14 -5
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.js +6 -0
- package/dist/core/cache.d.ts +1 -2
- package/dist/core/cache.js +9 -2
- package/dist/core/cli/stubs.d.ts +14 -0
- package/dist/core/cli/stubs.js +105 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/health.d.ts +2 -2
- package/dist/core/health.js +2 -2
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +25 -4
- package/dist/core/index.js +13 -3
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/logger.d.ts +7 -0
- package/dist/core/logger.js +28 -1
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.js +26 -5
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.js +15 -3
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +737 -3
- package/docs/api-resources.md +118 -0
- package/docs/configuration.md +74 -0
- package/docs/console.md +193 -13
- package/docs/database.md +101 -0
- package/docs/examples/configuration.ts +40 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/database.ts +90 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/logger.ts +74 -0
- package/docs/examples/mail.ts +103 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/testing.ts +149 -0
- package/docs/health.md +4 -4
- package/docs/i18n.md +302 -0
- package/docs/logger.md +156 -10
- package/docs/mail.md +73 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/storage.md +5 -3
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +3 -4
- package/docs/testing.md +251 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +2370 -248
- package/llms.txt +12 -1
- package/package.json +18 -2
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,8 +209,8 @@ 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,
|
|
210
|
-
| [Queues & Jobs](./docs/queues.md) | Dispatch jobs,
|
|
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 |
|
|
@@ -215,22 +218,28 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
|
|
|
215
218
|
| [Events](./docs/events.md) | Emit/listen decoupling, async listeners |
|
|
216
219
|
| [Service Broker](./docs/broker.md) | Moleculer-style services, call/emit, pluggable transport |
|
|
217
220
|
| [Cache](./docs/cache.md) | TTLs, the remember pattern, pluggable stores |
|
|
221
|
+
| [Locks](./docs/locks.md) | Distributed locks with ownership + TTL, pluggable stores |
|
|
218
222
|
| [Redis](./docs/redis.md) | Pluggable client, memory driver, cache adapter |
|
|
219
|
-
| [Logger](./docs/logger.md) | Structured logging, per-request `reqId`, redaction |
|
|
223
|
+
| [Logger](./docs/logger.md) | Structured logging, sinks, per-request `reqId`, redaction |
|
|
220
224
|
| [Static Files](./docs/static-files.md) | serveStatic(), caching, dot-file safety |
|
|
221
225
|
| [Storage](./docs/storage.md) | Pluggable disks (local/S3/R2), signed URLs, direct uploads |
|
|
222
226
|
| [Health Checks](./docs/health.md) | `/health/live` + `/health/ready`, pluggable checks |
|
|
227
|
+
| [Telemetry](./docs/telemetry.md) | Tracing, W3C context, OTLP export — no SDK |
|
|
228
|
+
| [Internationalization](./docs/i18n.md) | ICU messages, `Intl` formatters, locale detection |
|
|
229
|
+
| [Pages](./docs/pages.md) | Page-based routing — a file is a route |
|
|
230
|
+
| [Packages](./docs/packages.md) | Redistributable slices of an app: routes, migrations, commands |
|
|
231
|
+
| [Watch](./docs/watch.md) | Debug dashboard — requests, queries, jobs, logs at `/watch` |
|
|
223
232
|
| [Views](./docs/views.md) | Hono JSX components, layouts, the View service |
|
|
224
233
|
| [Templates](./docs/templates.md) | `{{ }}` + `@`-tag templating engine, edge-safe |
|
|
225
234
|
| [Middleware](./docs/middleware.md) | Global middleware, writing your own |
|
|
226
235
|
| [Rate Limiting](./docs/rate-limiting.md) | rateLimiter() middleware, per-key buckets |
|
|
227
236
|
| [Errors](./docs/errors.md) | HTTP exceptions, debug page, custom handlers |
|
|
228
|
-
| [Testing](./docs/testing.md) | Inject requests,
|
|
237
|
+
| [Testing](./docs/testing.md) | Inject requests, fakes, spies, time travel, db assertions |
|
|
229
238
|
| [Debugging](./docs/debugging.md) | dump() and dd() (dump-and-die) |
|
|
230
239
|
| [Validation](./docs/validation.md) | `validate()` with Zod, auto-422 field errors |
|
|
231
240
|
| [Inertia](./docs/inertia.md) | Server-side Inertia.js adapter |
|
|
232
241
|
| [Vite](./docs/vite.md) | Frontend build: HMR in dev, hashed manifest in prod |
|
|
233
|
-
| [The Console](./docs/console.md) |
|
|
242
|
+
| [The Console](./docs/console.md) | Typed commands, prompts, terminal UI, REPL, `make:*` |
|
|
234
243
|
| [Architecture](./docs/architecture.md) | Container, kernel, request lifecycle |
|
|
235
244
|
| [Built on Hono](./docs/hono.md) | The Hono layer underneath, and using it directly |
|
|
236
245
|
| [Building with AI](./docs/ai.md) | MCP server, `AGENTS.md`, `llms.txt`, agent workflow |
|
|
@@ -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,91 @@
|
|
|
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 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
|
+
}
|
|
91
|
+
export declare function apiResource(router: Router, model: ModelStatic, options?: ApiResourceOptions): void;
|
|
@@ -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
|
+
}
|
package/dist/core/application.js
CHANGED
|
@@ -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)),
|
package/dist/core/cache.d.ts
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* const stats = await cache().remember("stats", 60, () => computeStats());
|
|
8
8
|
*
|
|
9
|
-
*
|
|
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.
|
package/dist/core/cache.js
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* const stats = await cache().remember("stats", 60, () => computeStats());
|
|
8
8
|
*
|
|
9
|
-
*
|
|
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())
|
package/dist/core/cli/stubs.d.ts
CHANGED
|
@@ -8,5 +8,19 @@ export declare function factoryStub(model: string): string;
|
|
|
8
8
|
export declare function seederStub(name: string): string;
|
|
9
9
|
export declare function jobStub(name: string): string;
|
|
10
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;
|
|
11
17
|
/** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
|
|
12
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;
|