@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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# API Resources
|
|
2
|
+
|
|
3
|
+
`apiResource(router, Model, options)` generates a full CRUD REST API from a Keel
|
|
4
|
+
[model](./models.md) — the [Remult](https://remult.dev) idea done the Keel way:
|
|
5
|
+
explicit, server-side, and composed from pieces you already have. It's imported
|
|
6
|
+
from `@shaferllc/keel/api`.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { apiResource } from "@shaferllc/keel/api";
|
|
10
|
+
import { Post } from "../app/Models/Post.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
export default function routes(router) {
|
|
14
|
+
apiResource(router, Post, {
|
|
15
|
+
filter: ["status", "authorId"],
|
|
16
|
+
sort: ["createdAt", "title"],
|
|
17
|
+
body: z.object({ title: z.string(), body: z.string(), status: z.string() }),
|
|
18
|
+
access: { read: true, write: (c) => isEditor(c) },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That registers five routes:
|
|
24
|
+
|
|
25
|
+
| Method | Path | Action |
|
|
26
|
+
|--------|------|--------|
|
|
27
|
+
| `GET` | `/posts` | list (filtered, sorted, paginated) |
|
|
28
|
+
| `GET` | `/posts/:id` | read one |
|
|
29
|
+
| `POST` | `/posts` | create |
|
|
30
|
+
| `PUT` / `PATCH` | `/posts/:id` | update |
|
|
31
|
+
| `DELETE` | `/posts/:id` | delete |
|
|
32
|
+
|
|
33
|
+
They're real routes, so [`@shaferllc/keel/openapi`](./openapi.md) documents them
|
|
34
|
+
automatically, and writes go through the model's mass-assignment guard and your
|
|
35
|
+
Zod schema.
|
|
36
|
+
|
|
37
|
+
## Access is deny-by-default
|
|
38
|
+
|
|
39
|
+
An auto-generated API that's open by default is a footgun. Every action whose
|
|
40
|
+
access you don't declare returns **403**. You opt routes open — never shut.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
access: {
|
|
44
|
+
read: true, // list + read: anyone
|
|
45
|
+
write: (c) => auth().check(), // create + update + delete: signed-in only
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Rules resolve per action: the action's own key (`list`, `get`, `create`,
|
|
50
|
+
`update`, `delete`), then the `read` / `write` shorthand, then `all`, then denied.
|
|
51
|
+
Each rule is a boolean or a `(c) => boolean | Promise<boolean>` predicate.
|
|
52
|
+
|
|
53
|
+
## Filtering, sorting, pagination — allow-listed
|
|
54
|
+
|
|
55
|
+
The list endpoint reads the query string, but **only** columns you allow-list:
|
|
56
|
+
|
|
57
|
+
- `filter: ["status"]` → `GET /posts?status=published` filters; `?secret=x` is
|
|
58
|
+
ignored. Nothing reaches SQL unless it's on the list.
|
|
59
|
+
- `sort: ["title", "createdAt"]` → `GET /posts?sort=title,-createdAt` (a `-`
|
|
60
|
+
prefix is descending); unknown columns are dropped.
|
|
61
|
+
- `GET /posts?page=2&perPage=20` paginates. `perPage` is clamped to `maxPerPage`
|
|
62
|
+
(default 100) — the guard against "give me everything".
|
|
63
|
+
|
|
64
|
+
The response is a paginated envelope:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{ "data": [ … ], "meta": { "total": 42, "perPage": 20, "currentPage": 2, "lastPage": 3 } }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Row-level security with `scope`
|
|
71
|
+
|
|
72
|
+
`scope` constrains the base query for **every** row operation — list, read,
|
|
73
|
+
update, delete. A row outside the scope reads as 404, so it can't be fetched,
|
|
74
|
+
changed, or removed:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
apiResource(router, Post, {
|
|
78
|
+
access: { read: true, write: true },
|
|
79
|
+
scope: (q, c) => q.where("authorId", currentUserId(c)), // only your own posts
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Shaping input and output
|
|
84
|
+
|
|
85
|
+
- **`body` / `createBody` / `updateBody`** — a Zod schema validating writes
|
|
86
|
+
(a failure is a 422). It also becomes the request-body schema in the OpenAPI
|
|
87
|
+
docs.
|
|
88
|
+
- **`beforeWrite(data, c, action)`** — mutate the write payload (stamp an owner
|
|
89
|
+
id, a timestamp) before it's saved.
|
|
90
|
+
- **`transform`** — shape the output: a `(model, c) => …` function, or a Keel
|
|
91
|
+
[Transformer](./transformers.md) (its `item`/`collection` are used).
|
|
92
|
+
|
|
93
|
+
## Options reference
|
|
94
|
+
|
|
95
|
+
| Option | Purpose |
|
|
96
|
+
|--------|---------|
|
|
97
|
+
| `path` | Base path (default: the model's table). |
|
|
98
|
+
| `name` | Route-name prefix (default: the path). |
|
|
99
|
+
| `only` / `except` | Restrict which of the five actions are generated. |
|
|
100
|
+
| `filter` / `sort` | Allow-listed columns for `?filter` and `?sort`. |
|
|
101
|
+
| `perPage` / `maxPerPage` | Page-size default and ceiling. |
|
|
102
|
+
| `body` / `createBody` / `updateBody` | Write validation schemas. |
|
|
103
|
+
| `access` | Per-action access rules (deny by default). |
|
|
104
|
+
| `scope` | Row-level query constraint for every operation. |
|
|
105
|
+
| `transform` | Output shaping. |
|
|
106
|
+
| `beforeWrite` | Mutate the payload before save. |
|
|
107
|
+
| `tags` | OpenAPI tags for the routes. |
|
|
108
|
+
|
|
109
|
+
Global pagination defaults live in `config/api.ts` (register the optional
|
|
110
|
+
`ApiServiceProvider`, then `keel vendor:publish --tag api-config`).
|
|
111
|
+
|
|
112
|
+
## What this isn't
|
|
113
|
+
|
|
114
|
+
Remult also ships an isomorphic, type-safe **frontend** client that shares the
|
|
115
|
+
model with the server. Keel deliberately stops at the server boundary: this
|
|
116
|
+
generates a plain REST API you call however you like (fetch, your Inertia pages,
|
|
117
|
+
a mobile app). That keeps the model server-only and the wire contract explicit —
|
|
118
|
+
and it's exactly the contract the OpenAPI docs describe.
|
package/docs/configuration.md
CHANGED
|
@@ -55,6 +55,80 @@ string. Pass a fallback of the type you expect and the type follows it.
|
|
|
55
55
|
Use `env()` **only inside config files**, not scattered through your app. That
|
|
56
56
|
keeps all environment coupling in one layer.
|
|
57
57
|
|
|
58
|
+
## Validating the environment
|
|
59
|
+
|
|
60
|
+
`env("DATABASE_URL")` hands back whatever is — or isn't — in `process.env`. A
|
|
61
|
+
missing variable is `undefined`, the app boots looking perfectly healthy, and then
|
|
62
|
+
dies on the first request that actually needs it. In production. At night.
|
|
63
|
+
|
|
64
|
+
`defineEnv()` checks the whole environment **at boot** and refuses to start
|
|
65
|
+
otherwise:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// config/env.ts
|
|
69
|
+
import { defineEnv, envVar } from "@shaferllc/keel/core";
|
|
70
|
+
|
|
71
|
+
export const env = defineEnv({
|
|
72
|
+
APP_KEY: envVar.string({ required: true, description: "32+ random characters" }),
|
|
73
|
+
PORT: envVar.number({ default: 3000 }),
|
|
74
|
+
NODE_ENV: envVar.enum(["development", "test", "production"], { default: "development" }),
|
|
75
|
+
DATABASE_URL: envVar.url({ required: true }),
|
|
76
|
+
SENTRY_DSN: envVar.string(), // optional
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
env.PORT; // number — not "3000"
|
|
82
|
+
env.NODE_ENV; // "development" | "test" | "production" — not string
|
|
83
|
+
env.SENTRY_DSN; // string | undefined
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The types are **inferred from the rules**. A `number` rule gives you a `number`; an
|
|
87
|
+
`enum` gives you the union, not `string`; anything optional without a default is
|
|
88
|
+
`| undefined`, so you can't forget to handle it.
|
|
89
|
+
|
|
90
|
+
### It reports every problem at once
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
The environment is not valid:
|
|
94
|
+
|
|
95
|
+
• APP_KEY is required but not set (32+ random characters).
|
|
96
|
+
• PORT must be a number, got "eighty".
|
|
97
|
+
• NODE_ENV must be one of development, test, production, got "staging".
|
|
98
|
+
• DATABASE_URL must be a valid URL, got "not a url".
|
|
99
|
+
|
|
100
|
+
Set these in your .env (or your host's environment) and start again.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Not the first problem — **all** of them. Fixing a deploy one missing variable per
|
|
104
|
+
restart is its own small hell.
|
|
105
|
+
|
|
106
|
+
### Rules
|
|
107
|
+
|
|
108
|
+
| Rule | Value | Notes |
|
|
109
|
+
|------|-------|-------|
|
|
110
|
+
| `envVar.string()` | `string` | |
|
|
111
|
+
| `envVar.number()` | `number` | rejects `"eighty"` |
|
|
112
|
+
| `envVar.boolean()` | `boolean` | accepts `true/false/1/0/yes/no/on/off` |
|
|
113
|
+
| `envVar.enum([...])` | the union | typed as the literal union |
|
|
114
|
+
| `envVar.url()` | `string` | must parse as a URL — catches a truncated connection string |
|
|
115
|
+
|
|
116
|
+
Each takes `required`, `default`, `description` (shown in the error, so they know
|
|
117
|
+
what to set), and `validate` for anything else:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
APP_KEY: envVar.string({
|
|
121
|
+
required: true,
|
|
122
|
+
validate: (value) => (value.length >= 32 ? true : "must be at least 32 characters"),
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**An empty string counts as absent.** `PORT=` in a `.env` file is a typo, not a
|
|
127
|
+
deliberate empty port.
|
|
128
|
+
|
|
129
|
+
The returned object is frozen, so nothing can quietly reassign your config at
|
|
130
|
+
runtime.
|
|
131
|
+
|
|
58
132
|
## Config files
|
|
59
133
|
|
|
60
134
|
Each file in `config/` exports a default object and is loaded under its
|
package/docs/console.md
CHANGED
|
@@ -336,21 +336,201 @@ write the stub, confirming with:
|
|
|
336
336
|
|
|
337
337
|
Delete the existing file first if you truly mean to regenerate it.
|
|
338
338
|
|
|
339
|
-
##
|
|
339
|
+
## Writing your own commands
|
|
340
340
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
`program`:
|
|
341
|
+
`keel make:command greet` scaffolds `app/Commands/greet.ts`. Everything in
|
|
342
|
+
`app/Commands` is discovered automatically — no registration step.
|
|
344
343
|
|
|
345
344
|
```ts
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
})
|
|
345
|
+
import { defineCommand, arg, flag } from "@shaferllc/keel/core";
|
|
346
|
+
|
|
347
|
+
export const greet = defineCommand({
|
|
348
|
+
name: "greet",
|
|
349
|
+
description: "Greet someone",
|
|
350
|
+
|
|
351
|
+
args: { name: arg.string({ description: "who to greet" }) },
|
|
352
|
+
flags: { loud: flag.boolean({ alias: "l", description: "SHOUT IT" }) },
|
|
353
|
+
|
|
354
|
+
async run({ args, flags, ui }) {
|
|
355
|
+
const message = `Hello, ${args.name}!`;
|
|
356
|
+
ui.success(flags.loud ? message.toUpperCase() : message);
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
keel greet Ada --loud # ✔ HELLO, ADA!
|
|
363
|
+
keel greet --help # generated usage, args, and options
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**`args.name` is a `string` and `flags.loud` is a `boolean` — inferred, not cast.**
|
|
367
|
+
That's the point of declaring them: the parsing is generated from the types, so the
|
|
368
|
+
two can't drift apart. Make an arg optional and its type becomes
|
|
369
|
+
`string | undefined`; give it a default and it's a `string` again.
|
|
370
|
+
|
|
371
|
+
Commands run with the application booted, so they get the same container, config,
|
|
372
|
+
and providers your HTTP requests do.
|
|
373
|
+
|
|
374
|
+
### Arguments
|
|
375
|
+
|
|
376
|
+
Positional, in declaration order. Required by default.
|
|
377
|
+
|
|
378
|
+
| Builder | Value |
|
|
379
|
+
|---------|-------|
|
|
380
|
+
| `arg.string()` | `string` |
|
|
381
|
+
| `arg.number()` | `number` — rejected with a clear error if it isn't one |
|
|
382
|
+
| `arg.spread()` | `string[]` — swallows the rest; must be last |
|
|
383
|
+
|
|
384
|
+
Options: `description`, `required: false`, `default`, `parse`.
|
|
385
|
+
|
|
386
|
+
### Flags
|
|
387
|
+
|
|
388
|
+
| Builder | Value |
|
|
389
|
+
|---------|-------|
|
|
390
|
+
| `flag.boolean()` | `boolean` — defaults to `false`, so it's never `undefined` |
|
|
391
|
+
| `flag.string()` | `string \| undefined` |
|
|
392
|
+
| `flag.number()` | `number \| undefined` |
|
|
393
|
+
| `flag.array()` | `string[]` — repeatable, defaults to `[]` |
|
|
394
|
+
|
|
395
|
+
Options: `description`, `alias` (a single letter), `required`, `default`, `parse`.
|
|
396
|
+
|
|
397
|
+
The parser understands `--flag value`, `--flag=value`, `--no-flag`, `-f value`,
|
|
398
|
+
bundled shorthands (`-lt 5`), and `--`, after which everything is passed through
|
|
399
|
+
untouched in `rest`.
|
|
400
|
+
|
|
401
|
+
An **unknown flag is an error**, not a shrug — a typo'd `--forse` should tell you,
|
|
402
|
+
not silently do nothing. Set `allowUnknownFlags: true` if a command genuinely needs
|
|
403
|
+
to pass flags on to something else.
|
|
404
|
+
|
|
405
|
+
### Exit codes
|
|
406
|
+
|
|
407
|
+
Return a number to set the exit code; return nothing for `0`. A thrown error is
|
|
408
|
+
caught, reported, and exits `1` — a console is a bad place to show a user a stack
|
|
409
|
+
trace because they mistyped a flag. A **usage** error (missing arg, bad flag) prints
|
|
410
|
+
what's wrong *and the command's help*.
|
|
411
|
+
|
|
412
|
+
## Terminal UI
|
|
413
|
+
|
|
414
|
+
Every command gets a `ui`:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
ui.info("Checking…");
|
|
418
|
+
ui.success("Migrated 3 tables");
|
|
419
|
+
ui.warning("Nothing to do");
|
|
420
|
+
ui.error("Failed"); // stderr
|
|
421
|
+
ui.debug("verbose detail");
|
|
422
|
+
|
|
423
|
+
ui.action("create", "app/Models/User.ts"); // CREATE app/Models/User.ts
|
|
424
|
+
ui.action("skip", "app/Models/Post.ts", "skipped");
|
|
425
|
+
|
|
426
|
+
ui.table(["Name", "Rows"]).row(["users", "42"]).row(["orgs", "7"]).render();
|
|
427
|
+
|
|
428
|
+
ui.sticker(["http://localhost:3000"], "Server running");
|
|
429
|
+
ui.instructions(["cd my-app", "npm install", "keel serve"], "Next steps");
|
|
430
|
+
|
|
431
|
+
ui.colors("green", "done"); // paint a string yourself
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Tasks
|
|
435
|
+
|
|
436
|
+
For a command that does several things in a row:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
await ui
|
|
440
|
+
.tasks()
|
|
441
|
+
.add("Install dependencies", async (task) => {
|
|
442
|
+
task.update("resolving…");
|
|
443
|
+
return "42 packages";
|
|
444
|
+
})
|
|
445
|
+
.add("Run migrations", async () => "3 tables")
|
|
446
|
+
.run();
|
|
353
447
|
```
|
|
354
448
|
|
|
355
|
-
|
|
356
|
-
|
|
449
|
+
It **stops at the first failure**, because the tasks after it almost certainly
|
|
450
|
+
depended on it and a cascade of red tells you nothing new. `run()` resolves to
|
|
451
|
+
`false` if anything failed.
|
|
452
|
+
|
|
453
|
+
## Prompts
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
const name = await prompt.ask("Project name?", { default: "my-app" });
|
|
457
|
+
const secret = await prompt.secure("API key?");
|
|
458
|
+
const ok = await prompt.confirm("Delete everything?");
|
|
459
|
+
const driver = await prompt.choice("Database?", ["sqlite", "postgres"]);
|
|
460
|
+
const features = await prompt.multiple("Features?", ["auth", "queue", "mail"]);
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
`ask` re-asks on a failed `validate` rather than dying — a typo shouldn't cost
|
|
464
|
+
someone the whole command. Every prompt takes `default`, `hint`, `validate`, and
|
|
465
|
+
`result`.
|
|
466
|
+
|
|
467
|
+
## Testing a command
|
|
468
|
+
|
|
469
|
+
A command that asks questions is normally a command you can't test. So prompts can
|
|
470
|
+
be **trapped**: script the answers up front, and nothing touches the terminal.
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
import { ConsoleKernel, createUi, createPrompt } from "@shaferllc/keel/core";
|
|
474
|
+
|
|
475
|
+
const ui = createUi({ raw: true }); // buffer the output, drop the colors
|
|
476
|
+
const prompt = createPrompt({ trap: true });
|
|
477
|
+
const kernel = new ConsoleKernel({ ui, prompt }).register(setup);
|
|
478
|
+
|
|
479
|
+
prompt.trap("Project name?").replyWith("keel-app");
|
|
480
|
+
prompt.trap("Database?").chooseOption(1);
|
|
481
|
+
prompt.trap("Write the config?").accept();
|
|
482
|
+
|
|
483
|
+
const code = await kernel.run(["setup"]);
|
|
484
|
+
|
|
485
|
+
assert.equal(code, 0);
|
|
486
|
+
assert.match(ui.logs.join("\n"), /keel-app on postgres/);
|
|
487
|
+
prompt.assertAllTrapsUsed(); // every scripted question was actually asked
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
An **untrapped prompt throws** instead of hanging. That matters more than it
|
|
491
|
+
sounds: without it, the test would block forever on stdin no test will ever
|
|
492
|
+
provide, and your suite would simply stop — with no failure to read.
|
|
493
|
+
|
|
494
|
+
A trap can also assert the prompt's own validation:
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
prompt
|
|
498
|
+
.trap("Email?")
|
|
499
|
+
.assertFails("", "Email is required")
|
|
500
|
+
.assertPasses("ada@example.com")
|
|
501
|
+
.replyWith("ada@example.com");
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
`ui.logs` and `ui.errors` hold every line written, colorless, so you can assert on
|
|
505
|
+
exactly what the command said.
|
|
506
|
+
|
|
507
|
+
## The REPL
|
|
508
|
+
|
|
509
|
+
```bash
|
|
510
|
+
keel repl
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
An interactive shell with the **application booted** — the container is up, the
|
|
514
|
+
providers have run, and the helpers are in scope:
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
keel > await db("users").where("active", 1).get()
|
|
518
|
+
keel > make(Router).all()
|
|
519
|
+
keel > await cache().get("stats")
|
|
520
|
+
keel > .ls # what's in scope
|
|
521
|
+
keel > .exit
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Poking at a model in a REPL is the fastest debugging loop there is, and it
|
|
525
|
+
shouldn't cost you a throwaway script to get one. History persists in
|
|
526
|
+
`.keel_repl_history`.
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
## A note on the built-ins
|
|
531
|
+
|
|
532
|
+
The commands *above* (`serve`, `routes`, `make:*`, `migrate:*`) still run through
|
|
533
|
+
Keel's original console wrapper, and package-contributed commands do too. Your
|
|
534
|
+
commands — anything in `app/Commands` — run on the system documented here, and take
|
|
535
|
+
precedence over a built-in of the same name. Migrating the built-ins across is
|
|
536
|
+
mechanical and will happen; nothing about the API here changes when it does.
|
package/docs/database.md
CHANGED
|
@@ -178,6 +178,107 @@ SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
|
|
|
178
178
|
> matches the current `where` clause — with no `where`, that's the whole table.
|
|
179
179
|
> Always scope a write with `where` unless you truly mean to touch every row.
|
|
180
180
|
|
|
181
|
+
## Transactions
|
|
182
|
+
|
|
183
|
+
Two related writes should either both land or neither should. `transaction()`
|
|
184
|
+
commits when your callback returns and **rolls back if it throws**:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { transaction, db } from "@shaferllc/keel/core";
|
|
188
|
+
|
|
189
|
+
await transaction(async () => {
|
|
190
|
+
await db("orders").insert(order);
|
|
191
|
+
await db("stock").where("id", id).decrement("count"); // a throw here undoes the insert
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The error still reaches you — it's rethrown after the rollback. Nothing is
|
|
196
|
+
swallowed.
|
|
197
|
+
|
|
198
|
+
### Queries inside are ambient
|
|
199
|
+
|
|
200
|
+
You don't have to thread a transaction object through your code. `db()`, models,
|
|
201
|
+
and relations all pick up the open transaction automatically:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
await transaction(async () => {
|
|
205
|
+
const user = await User.create({ email }); // the model is in the transaction
|
|
206
|
+
await user.related("posts").create({ title }); // so is the relation
|
|
207
|
+
await db("audit").insert({ userId: user.id }); // and the raw builder
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
That works because the transaction lives in `AsyncLocalStorage`, not a module
|
|
212
|
+
global — so two requests running transactions at the same time can't steal each
|
|
213
|
+
other's connection.
|
|
214
|
+
|
|
215
|
+
If you'd rather be explicit, the callback gets a handle:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
await transaction(async (tx) => {
|
|
219
|
+
await tx.table("orders").insert(order);
|
|
220
|
+
await tx.write("UPDATE stock SET count = count - 1 WHERE id = ?", [id]);
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`tx.rollback()` abandons the transaction without committing. `inTransaction()`
|
|
225
|
+
tells you whether one is open.
|
|
226
|
+
|
|
227
|
+
### Nesting uses savepoints
|
|
228
|
+
|
|
229
|
+
A `transaction()` inside another doesn't open a second one — databases don't have
|
|
230
|
+
those. It takes a **savepoint**, so an inner failure rolls back only the inner
|
|
231
|
+
work and the outer transaction carries on:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
await transaction(async () => {
|
|
235
|
+
await db("orders").insert(order); // survives
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await transaction(async () => {
|
|
239
|
+
await db("items").insert(item);
|
|
240
|
+
throw new Error("out of stock"); // only this is rolled back
|
|
241
|
+
});
|
|
242
|
+
} catch {
|
|
243
|
+
// handle it
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await db("audit").insert(entry); // still in the outer transaction
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// the outer transaction commits: the order and the audit row are both saved
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Without savepoints, a nested helper's failure would silently abandon its caller's
|
|
253
|
+
writes too — which is the sort of bug you find in production, months later.
|
|
254
|
+
|
|
255
|
+
### Drivers and the pooling trap
|
|
256
|
+
|
|
257
|
+
A transaction needs every statement to run on **one** connection. A connection
|
|
258
|
+
*pool* hands each statement to whichever connection is free — so issuing `BEGIN`
|
|
259
|
+
through a pool wraps nothing: the `INSERT` after it can land on a different
|
|
260
|
+
connection entirely, the `COMMIT` commits nothing, and a failure half-writes.
|
|
261
|
+
It looks like it works. It doesn't.
|
|
262
|
+
|
|
263
|
+
So a pooled driver implements `begin()` on its `Connection`, checking one
|
|
264
|
+
connection out and running the whole transaction on it. Keel's Postgres adapter
|
|
265
|
+
does this automatically when you hand it a `Pool` (it checks for `connect()`), and
|
|
266
|
+
releases the connection afterwards even if the `COMMIT` throws.
|
|
267
|
+
|
|
268
|
+
| Driver | Transactions |
|
|
269
|
+
|--------|--------------|
|
|
270
|
+
| Postgres (`Pool`) | ✅ a dedicated connection is checked out |
|
|
271
|
+
| Postgres (`Client`), SQLite, libSQL | ✅ `BEGIN` / `COMMIT` on the one connection they have |
|
|
272
|
+
| **Cloudflare D1** | ❌ — no interactive transactions; use `database.batch([...])` |
|
|
273
|
+
|
|
274
|
+
D1 can't hold a transaction open across awaits, so `transaction()` on it **throws
|
|
275
|
+
a clear error** rather than letting a `BEGIN` fail cryptically. A transaction that
|
|
276
|
+
quietly isn't one is far worse than one that refuses to start.
|
|
277
|
+
|
|
278
|
+
Writing your own driver? Implement `begin(): Promise<TransactionConnection>` if it
|
|
279
|
+
pools. If it owns a single connection, you can leave it out and Keel will use
|
|
280
|
+
`BEGIN`/`COMMIT`/`ROLLBACK`.
|
|
281
|
+
|
|
181
282
|
## Typed rows
|
|
182
283
|
|
|
183
284
|
Pass a row type for typed results — it flows through to `get()` and `first()`:
|
|
@@ -79,3 +79,43 @@ const data: ConfigData = {
|
|
|
79
79
|
app: { name: "Keel", port: 3000 },
|
|
80
80
|
};
|
|
81
81
|
export { data };
|
|
82
|
+
|
|
83
|
+
/* --- Environment validation --- */
|
|
84
|
+
|
|
85
|
+
import { defineEnv, envVar, EnvValidationError } from "@shaferllc/keel/core";
|
|
86
|
+
|
|
87
|
+
export const validated = defineEnv({
|
|
88
|
+
APP_KEY: envVar.string({ required: true, description: "32+ random characters" }),
|
|
89
|
+
PORT: envVar.number({ default: 3000 }),
|
|
90
|
+
NODE_ENV: envVar.enum(["development", "test", "production"], { default: "development" }),
|
|
91
|
+
DATABASE_URL: envVar.url({ required: true }),
|
|
92
|
+
DEBUG: envVar.boolean({ default: false }),
|
|
93
|
+
SENTRY_DSN: envVar.string(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export function typedValues() {
|
|
97
|
+
const key: string = validated.APP_KEY;
|
|
98
|
+
const port: number = validated.PORT;
|
|
99
|
+
const mode: "development" | "test" | "production" = validated.NODE_ENV;
|
|
100
|
+
const debug: boolean = validated.DEBUG;
|
|
101
|
+
const dsn: string | undefined = validated.SENTRY_DSN;
|
|
102
|
+
const url: string = validated.DATABASE_URL;
|
|
103
|
+
|
|
104
|
+
return { key, port, mode, debug, dsn, url };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function withCustomValidation() {
|
|
108
|
+
return defineEnv(
|
|
109
|
+
{
|
|
110
|
+
APP_KEY: envVar.string({
|
|
111
|
+
required: true,
|
|
112
|
+
validate: (value) => (value.length >= 32 ? true : "must be at least 32 characters"),
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
{ source: { APP_KEY: "x".repeat(32) } },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function handlingTheFailure(error: unknown): string[] {
|
|
120
|
+
return error instanceof EnvValidationError ? error.problems : [];
|
|
121
|
+
}
|