@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.
Files changed (140) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +14 -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 +91 -0
  13. package/dist/api/resource.js +188 -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/stubs.d.ts +14 -0
  18. package/dist/core/cli/stubs.js +105 -0
  19. package/dist/core/console-prompt.d.ts +79 -0
  20. package/dist/core/console-prompt.js +239 -0
  21. package/dist/core/console-ui.d.ts +96 -0
  22. package/dist/core/console-ui.js +187 -0
  23. package/dist/core/console.d.ts +188 -0
  24. package/dist/core/console.js +395 -0
  25. package/dist/core/database.d.ts +70 -1
  26. package/dist/core/database.js +174 -15
  27. package/dist/core/env.d.ts +96 -0
  28. package/dist/core/env.js +140 -0
  29. package/dist/core/health.d.ts +2 -2
  30. package/dist/core/health.js +2 -2
  31. package/dist/core/http/kernel.d.ts +2 -0
  32. package/dist/core/http/kernel.js +48 -0
  33. package/dist/core/http/router.d.ts +5 -5
  34. package/dist/core/http/router.js +5 -5
  35. package/dist/core/i18n.d.ts +162 -0
  36. package/dist/core/i18n.js +472 -0
  37. package/dist/core/index.d.ts +25 -4
  38. package/dist/core/index.js +13 -3
  39. package/dist/core/instrumentation.d.ts +113 -0
  40. package/dist/core/instrumentation.js +52 -0
  41. package/dist/core/logger.d.ts +7 -0
  42. package/dist/core/logger.js +28 -1
  43. package/dist/core/notification.js +10 -1
  44. package/dist/core/package.d.ts +120 -0
  45. package/dist/core/package.js +169 -0
  46. package/dist/core/pages.d.ts +108 -0
  47. package/dist/core/pages.js +199 -0
  48. package/dist/core/queue.js +26 -5
  49. package/dist/core/repl.d.ts +33 -0
  50. package/dist/core/repl.js +88 -0
  51. package/dist/core/scheduler.js +6 -0
  52. package/dist/core/social.d.ts +4 -4
  53. package/dist/core/social.js +4 -4
  54. package/dist/core/storage.js +15 -3
  55. package/dist/core/telemetry.d.ts +208 -0
  56. package/dist/core/telemetry.js +383 -0
  57. package/dist/core/template.d.ts +2 -3
  58. package/dist/core/template.js +2 -3
  59. package/dist/core/testing.d.ts +170 -1
  60. package/dist/core/testing.js +504 -2
  61. package/dist/db/d1.js +13 -0
  62. package/dist/db/pg.d.ts +13 -0
  63. package/dist/db/pg.js +46 -4
  64. package/dist/openapi/config.d.ts +28 -0
  65. package/dist/openapi/config.js +25 -0
  66. package/dist/openapi/doc.d.ts +40 -0
  67. package/dist/openapi/doc.js +20 -0
  68. package/dist/openapi/export.d.ts +8 -0
  69. package/dist/openapi/export.js +19 -0
  70. package/dist/openapi/gate.d.ts +15 -0
  71. package/dist/openapi/gate.js +27 -0
  72. package/dist/openapi/index.d.ts +19 -0
  73. package/dist/openapi/index.js +15 -0
  74. package/dist/openapi/openapi.config.stub +29 -0
  75. package/dist/openapi/provider.d.ts +18 -0
  76. package/dist/openapi/provider.js +35 -0
  77. package/dist/openapi/routes.d.ts +9 -0
  78. package/dist/openapi/routes.js +23 -0
  79. package/dist/openapi/spec.d.ts +23 -0
  80. package/dist/openapi/spec.js +132 -0
  81. package/dist/openapi/ui.d.ts +8 -0
  82. package/dist/openapi/ui.js +31 -0
  83. package/dist/openapi/zod.d.ts +12 -0
  84. package/dist/openapi/zod.js +46 -0
  85. package/dist/watch/config.d.ts +33 -0
  86. package/dist/watch/config.js +38 -0
  87. package/dist/watch/entry.d.ts +53 -0
  88. package/dist/watch/entry.js +105 -0
  89. package/dist/watch/gate.d.ts +20 -0
  90. package/dist/watch/gate.js +32 -0
  91. package/dist/watch/index.d.ts +21 -0
  92. package/dist/watch/index.js +17 -0
  93. package/dist/watch/migration.d.ts +7 -0
  94. package/dist/watch/migration.js +20 -0
  95. package/dist/watch/provider.d.ts +22 -0
  96. package/dist/watch/provider.js +58 -0
  97. package/dist/watch/prune.d.ts +11 -0
  98. package/dist/watch/prune.js +20 -0
  99. package/dist/watch/recorder.d.ts +24 -0
  100. package/dist/watch/recorder.js +39 -0
  101. package/dist/watch/routes.d.ts +13 -0
  102. package/dist/watch/routes.js +55 -0
  103. package/dist/watch/store.d.ts +54 -0
  104. package/dist/watch/store.js +158 -0
  105. package/dist/watch/ui/dist/watch.css +1 -0
  106. package/dist/watch/ui/dist/watch.js +555 -0
  107. package/dist/watch/ui-shell.d.ts +12 -0
  108. package/dist/watch/ui-shell.js +24 -0
  109. package/dist/watch/watch.config.stub +47 -0
  110. package/dist/watch/watchers.d.ts +12 -0
  111. package/dist/watch/watchers.js +156 -0
  112. package/docs/ai-manifest.json +737 -3
  113. package/docs/api-resources.md +118 -0
  114. package/docs/configuration.md +74 -0
  115. package/docs/console.md +193 -13
  116. package/docs/database.md +101 -0
  117. package/docs/examples/configuration.ts +40 -0
  118. package/docs/examples/console.ts +134 -0
  119. package/docs/examples/database.ts +90 -0
  120. package/docs/examples/i18n.ts +117 -0
  121. package/docs/examples/logger.ts +74 -0
  122. package/docs/examples/mail.ts +103 -0
  123. package/docs/examples/pages.ts +82 -0
  124. package/docs/examples/telemetry.ts +127 -0
  125. package/docs/examples/testing.ts +149 -0
  126. package/docs/health.md +4 -4
  127. package/docs/i18n.md +302 -0
  128. package/docs/logger.md +156 -10
  129. package/docs/mail.md +73 -0
  130. package/docs/openapi.md +111 -0
  131. package/docs/packages.md +118 -0
  132. package/docs/pages.md +217 -0
  133. package/docs/storage.md +5 -3
  134. package/docs/telemetry.md +263 -0
  135. package/docs/templates.md +3 -4
  136. package/docs/testing.md +251 -0
  137. package/docs/watch.md +118 -0
  138. package/llms-full.txt +2370 -248
  139. package/llms.txt +12 -1
  140. 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.
@@ -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
- ## Adding your own commands
339
+ ## Writing your own commands
340
340
 
341
- Commands are defined with [commander](https://github.com/tj/commander.js) in
342
- [`src/core/cli/index.ts`](../src/core/cli/index.ts). Register a new one on the
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
- program
347
- .command("cache:clear")
348
- .description("Clear the application cache")
349
- .action(async () => {
350
- const app = await createApplication();
351
- // ...your logic, with full access to the container
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
- Because commands boot the application, they get the same container, config, and
356
- providers your HTTP requests do.
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
+ }