@shaferllc/keel 0.68.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +15 -5
  3. package/dist/api/api.config.stub +9 -0
  4. package/dist/api/config.d.ts +13 -0
  5. package/dist/api/config.js +14 -0
  6. package/dist/api/index.d.ts +16 -0
  7. package/dist/api/index.js +13 -0
  8. package/dist/api/provider.d.ts +10 -0
  9. package/dist/api/provider.js +17 -0
  10. package/dist/api/query.d.ts +35 -0
  11. package/dist/api/query.js +42 -0
  12. package/dist/api/resource.d.ts +93 -0
  13. package/dist/api/resource.js +185 -0
  14. package/dist/core/application.js +6 -0
  15. package/dist/core/cache.d.ts +1 -2
  16. package/dist/core/cache.js +9 -2
  17. package/dist/core/cli/index.d.ts +28 -0
  18. package/dist/core/cli/index.js +427 -0
  19. package/dist/core/cli/stubs.d.ts +14 -0
  20. package/dist/core/cli/stubs.js +105 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/health.d.ts +2 -2
  32. package/dist/core/health.js +2 -2
  33. package/dist/core/http/kernel.d.ts +2 -0
  34. package/dist/core/http/kernel.js +48 -0
  35. package/dist/core/http/router.d.ts +5 -5
  36. package/dist/core/http/router.js +5 -5
  37. package/dist/core/i18n.d.ts +162 -0
  38. package/dist/core/i18n.js +472 -0
  39. package/dist/core/index.d.ts +25 -4
  40. package/dist/core/index.js +13 -3
  41. package/dist/core/instrumentation.d.ts +113 -0
  42. package/dist/core/instrumentation.js +52 -0
  43. package/dist/core/logger.d.ts +7 -0
  44. package/dist/core/logger.js +28 -1
  45. package/dist/core/notification.js +10 -1
  46. package/dist/core/package.d.ts +117 -0
  47. package/dist/core/package.js +169 -0
  48. package/dist/core/pages.d.ts +108 -0
  49. package/dist/core/pages.js +199 -0
  50. package/dist/core/queue.js +26 -5
  51. package/dist/core/repl.d.ts +33 -0
  52. package/dist/core/repl.js +88 -0
  53. package/dist/core/scheduler.js +6 -0
  54. package/dist/core/social.d.ts +4 -4
  55. package/dist/core/social.js +4 -4
  56. package/dist/core/storage.js +15 -3
  57. package/dist/core/telemetry.d.ts +208 -0
  58. package/dist/core/telemetry.js +383 -0
  59. package/dist/core/template.d.ts +2 -3
  60. package/dist/core/template.js +2 -3
  61. package/dist/core/testing.d.ts +171 -1
  62. package/dist/core/testing.js +505 -2
  63. package/dist/db/d1.js +13 -0
  64. package/dist/db/pg.d.ts +13 -0
  65. package/dist/db/pg.js +46 -4
  66. package/dist/openapi/config.d.ts +28 -0
  67. package/dist/openapi/config.js +25 -0
  68. package/dist/openapi/doc.d.ts +40 -0
  69. package/dist/openapi/doc.js +20 -0
  70. package/dist/openapi/export.d.ts +8 -0
  71. package/dist/openapi/export.js +19 -0
  72. package/dist/openapi/gate.d.ts +15 -0
  73. package/dist/openapi/gate.js +27 -0
  74. package/dist/openapi/index.d.ts +19 -0
  75. package/dist/openapi/index.js +15 -0
  76. package/dist/openapi/openapi.config.stub +29 -0
  77. package/dist/openapi/provider.d.ts +18 -0
  78. package/dist/openapi/provider.js +35 -0
  79. package/dist/openapi/routes.d.ts +9 -0
  80. package/dist/openapi/routes.js +23 -0
  81. package/dist/openapi/spec.d.ts +23 -0
  82. package/dist/openapi/spec.js +132 -0
  83. package/dist/openapi/ui.d.ts +8 -0
  84. package/dist/openapi/ui.js +31 -0
  85. package/dist/openapi/zod.d.ts +12 -0
  86. package/dist/openapi/zod.js +46 -0
  87. package/dist/watch/config.d.ts +33 -0
  88. package/dist/watch/config.js +38 -0
  89. package/dist/watch/entry.d.ts +53 -0
  90. package/dist/watch/entry.js +105 -0
  91. package/dist/watch/gate.d.ts +20 -0
  92. package/dist/watch/gate.js +32 -0
  93. package/dist/watch/index.d.ts +21 -0
  94. package/dist/watch/index.js +17 -0
  95. package/dist/watch/migration.d.ts +7 -0
  96. package/dist/watch/migration.js +20 -0
  97. package/dist/watch/provider.d.ts +22 -0
  98. package/dist/watch/provider.js +58 -0
  99. package/dist/watch/prune.d.ts +11 -0
  100. package/dist/watch/prune.js +22 -0
  101. package/dist/watch/recorder.d.ts +24 -0
  102. package/dist/watch/recorder.js +39 -0
  103. package/dist/watch/routes.d.ts +13 -0
  104. package/dist/watch/routes.js +55 -0
  105. package/dist/watch/store.d.ts +54 -0
  106. package/dist/watch/store.js +158 -0
  107. package/dist/watch/ui/dist/watch.css +1 -0
  108. package/dist/watch/ui/dist/watch.js +555 -0
  109. package/dist/watch/ui-shell.d.ts +12 -0
  110. package/dist/watch/ui-shell.js +24 -0
  111. package/dist/watch/watch.config.stub +47 -0
  112. package/dist/watch/watchers.d.ts +12 -0
  113. package/dist/watch/watchers.js +156 -0
  114. package/docs/ai-manifest.json +737 -3
  115. package/docs/api-resources.md +119 -0
  116. package/docs/configuration.md +74 -0
  117. package/docs/console.md +217 -13
  118. package/docs/database.md +101 -0
  119. package/docs/examples/api-resources.ts +86 -0
  120. package/docs/examples/configuration.ts +40 -0
  121. package/docs/examples/console.ts +134 -0
  122. package/docs/examples/database.ts +90 -0
  123. package/docs/examples/i18n.ts +117 -0
  124. package/docs/examples/logger.ts +74 -0
  125. package/docs/examples/mail.ts +103 -0
  126. package/docs/examples/pages.ts +82 -0
  127. package/docs/examples/telemetry.ts +127 -0
  128. package/docs/examples/testing.ts +150 -0
  129. package/docs/health.md +4 -4
  130. package/docs/i18n.md +302 -0
  131. package/docs/logger.md +156 -10
  132. package/docs/mail.md +73 -0
  133. package/docs/openapi.md +111 -0
  134. package/docs/packages.md +118 -0
  135. package/docs/pages.md +217 -0
  136. package/docs/storage.md +5 -3
  137. package/docs/telemetry.md +263 -0
  138. package/docs/templates.md +3 -4
  139. package/docs/testing.md +252 -0
  140. package/docs/watch.md +118 -0
  141. package/llms-full.txt +2389 -241
  142. package/llms.txt +13 -1
  143. package/package.json +32 -6
@@ -9,6 +9,38 @@
9
9
  * const user = await db("users").where("id", 1).first();
10
10
  * await db("users").insert({ email });
11
11
  */
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { instrument, currentRequestId } from "./instrumentation.js";
14
+ /**
15
+ * Run a row-returning query on a source and report it to the instrumentation
16
+ * stream (`db.query`) once it settles. Emitting here — the one place every
17
+ * builder and raw handle funnels through — means a package can watch every query
18
+ * without the query layer knowing anything about it.
19
+ */
20
+ async function selectOn(source, sql, bindings) {
21
+ const start = Date.now();
22
+ const rows = await source.conn.select(placeholders(sql, source.dialect), bindings);
23
+ reportQuery("select", sql, bindings, source, start);
24
+ return rows;
25
+ }
26
+ /** Run a write on a source and report it to the instrumentation stream. */
27
+ async function writeOn(source, sql, bindings) {
28
+ const start = Date.now();
29
+ const result = await source.conn.write(placeholders(sql, source.dialect), bindings);
30
+ reportQuery("write", sql, bindings, source, start);
31
+ return result;
32
+ }
33
+ function reportQuery(kind, sql, bindings, source, start) {
34
+ const requestId = currentRequestId();
35
+ instrument("db.query", {
36
+ sql,
37
+ bindings,
38
+ durationMs: Date.now() - start,
39
+ connection: source.name,
40
+ kind,
41
+ ...(requestId ? { requestId } : {}),
42
+ });
43
+ }
12
44
  /**
13
45
  * The connection registry. An app can talk to several databases at once —
14
46
  * register each by name, then route a query with `db(table, name)`, a whole
@@ -20,7 +52,7 @@ const registry = new Map();
20
52
  let defaultConnection = "default";
21
53
  /** Register the default connection (and dialect) used by `db()`. */
22
54
  export function setConnection(conn, driverDialect = "sqlite") {
23
- registry.set("default", { conn, dialect: driverDialect });
55
+ registry.set("default", { name: "default", conn, dialect: driverDialect });
24
56
  defaultConnection = "default";
25
57
  }
26
58
  /**
@@ -31,7 +63,7 @@ export function setConnection(conn, driverDialect = "sqlite") {
31
63
  * await db("events", "reporting").where("kind", "signup").count();
32
64
  */
33
65
  export function addConnection(name, conn, driverDialect = "sqlite") {
34
- registry.set(name, { conn, dialect: driverDialect });
66
+ registry.set(name, { name, conn, dialect: driverDialect });
35
67
  }
36
68
  /** Choose which registered connection `db()` and models use when none is named. */
37
69
  export function setDefaultConnection(name) {
@@ -48,8 +80,17 @@ export function clearConnections() {
48
80
  registry.clear();
49
81
  defaultConnection = "default";
50
82
  }
51
- /** Resolve a connection by name (or the default); throws if it isn't registered. */
52
- function resolve(name) {
83
+ /**
84
+ * The transactions open on the *current async context*, keyed by connection name.
85
+ *
86
+ * This is what makes an ambient transaction work: `db("users")` inside
87
+ * `transaction(...)` resolves to the transaction's connection without anyone
88
+ * passing it down. `AsyncLocalStorage` (not a module global) is what keeps two
89
+ * concurrent requests from stealing each other's transaction.
90
+ */
91
+ const openTransactions = new AsyncLocalStorage();
92
+ /** The registered connection under a name — ignoring any open transaction. */
93
+ function lookup(name) {
53
94
  const source = registry.get(name ?? defaultConnection);
54
95
  if (!source) {
55
96
  throw new Error(`No database connection${name ? ` "${name}"` : ""}. ` +
@@ -57,6 +98,15 @@ function resolve(name) {
57
98
  }
58
99
  return source;
59
100
  }
101
+ /**
102
+ * Resolve a connection by name (or the default); throws if it isn't registered.
103
+ * A transaction open on that connection wins — that's the ambient part.
104
+ */
105
+ function resolve(name) {
106
+ const key = name ?? defaultConnection;
107
+ const open = openTransactions.getStore()?.get(key);
108
+ return open ? open.source : lookup(key);
109
+ }
60
110
  /** Render `?` placeholders for a dialect (Postgres uses $1, $2, …). */
61
111
  function placeholders(sql, dialect) {
62
112
  if (dialect !== "postgres")
@@ -81,13 +131,11 @@ export class QueryBuilder {
81
131
  }
82
132
  /** Run a row-returning query on this builder's connection, dialect-adjusted. */
83
133
  runSelect(sql, bindings) {
84
- const source = this.getSource();
85
- return source.conn.select(placeholders(sql, source.dialect), bindings);
134
+ return selectOn(this.getSource(), sql, bindings);
86
135
  }
87
136
  /** Run a write on this builder's connection, dialect-adjusted. */
88
137
  runWrite(sql, bindings) {
89
- const source = this.getSource();
90
- return source.conn.write(placeholders(sql, source.dialect), bindings);
138
+ return writeOn(this.getSource(), sql, bindings);
91
139
  }
92
140
  select(...columns) {
93
141
  this.columns = columns.length ? columns.join(", ") : "*";
@@ -252,20 +300,131 @@ export class QueryBuilder {
252
300
  export function db(table, connectionName) {
253
301
  return new QueryBuilder(table, () => resolve(connectionName));
254
302
  }
303
+ function handleFor(source, depth, rollback) {
304
+ return {
305
+ table: (t) => new QueryBuilder(t, () => source),
306
+ select: (sql, bindings = []) => selectOn(source, sql, bindings),
307
+ write: (sql, bindings = []) => writeOn(source, sql, bindings),
308
+ dialect: source.dialect,
309
+ depth,
310
+ rollback,
311
+ };
312
+ }
313
+ /** Open a transaction: the driver's own if it has one, else BEGIN on what we have. */
314
+ async function begin(source) {
315
+ if (source.conn.begin) {
316
+ const tx = await source.conn.begin();
317
+ return {
318
+ source: { name: source.name, conn: tx, dialect: source.dialect },
319
+ commit: () => tx.commit(),
320
+ rollback: () => tx.rollback(),
321
+ };
322
+ }
323
+ await writeOn(source, "BEGIN", []);
324
+ return {
325
+ source,
326
+ commit: async () => void (await writeOn(source, "COMMIT", [])),
327
+ rollback: async () => void (await writeOn(source, "ROLLBACK", [])),
328
+ };
329
+ }
255
330
  /**
256
- * Get a handle to a named connection (or the default). Use it to run several
257
- * queries against one database without repeating the name, or to reach the raw
258
- * `select`/`write` bridge.
331
+ * Run `fn` inside a database transaction. It commits when `fn` returns, and
332
+ * **rolls back if `fn` throws** which is the entire point: two related writes
333
+ * either both land or neither does, so a failure between them can't leave the row
334
+ * charged and the order missing.
335
+ *
336
+ * await transaction(async () => {
337
+ * await db("orders").insert(order);
338
+ * await db("stock").where("id", id).decrement("count"); // a throw here undoes the insert
339
+ * });
340
+ *
341
+ * Queries inside are **ambient**: `db()`, models, and relations all pick up the
342
+ * transaction's connection without being handed it. The callback also gets an
343
+ * explicit handle if you'd rather be obvious about it:
344
+ *
345
+ * await transaction(async (tx) => {
346
+ * await tx.table("orders").insert(order);
347
+ * });
259
348
  *
260
- * const reporting = connection("reporting");
261
- * await reporting.table("events").where("kind", "signup").count();
349
+ * **Nesting uses savepoints.** A `transaction()` inside another doesn't open a
350
+ * second transaction — databases don't have those — it takes a savepoint, so an
351
+ * inner failure rolls back only the inner work and the outer transaction carries
352
+ * on. Without that, a nested helper's failure would silently abandon its caller's
353
+ * writes too.
262
354
  */
355
+ export async function transaction(fn, connectionName) {
356
+ const key = connectionName ?? defaultConnection;
357
+ const store = openTransactions.getStore();
358
+ const open = store?.get(key);
359
+ /* ----------------------------- nested: savepoint ---------------------- */
360
+ if (open) {
361
+ const depth = open.depth + 1;
362
+ const name = `keel_sp_${depth}`;
363
+ const source = open.source;
364
+ await writeOn(source, `SAVEPOINT ${name}`, []);
365
+ const nested = new Map(store);
366
+ nested.set(key, { source, depth });
367
+ let undone = false;
368
+ const undo = async () => {
369
+ undone = true;
370
+ await writeOn(source, `ROLLBACK TO SAVEPOINT ${name}`, []);
371
+ };
372
+ try {
373
+ const result = await openTransactions.run(nested, () => fn(handleFor(source, depth, undo)));
374
+ if (!undone)
375
+ await writeOn(source, `RELEASE SAVEPOINT ${name}`, []);
376
+ return result;
377
+ }
378
+ catch (error) {
379
+ // Only this savepoint is undone — the transaction around it survives.
380
+ if (!undone)
381
+ await writeOn(source, `ROLLBACK TO SAVEPOINT ${name}`, []);
382
+ throw error;
383
+ }
384
+ }
385
+ /* ---------------------------- outermost ------------------------------- */
386
+ const tx = await begin(lookup(key));
387
+ const next = new Map(store ?? []);
388
+ next.set(key, { source: tx.source, depth: 0 });
389
+ let undone = false;
390
+ const undo = async () => {
391
+ undone = true;
392
+ await tx.rollback();
393
+ };
394
+ try {
395
+ const result = await openTransactions.run(next, () => fn(handleFor(tx.source, 0, undo)));
396
+ if (!undone)
397
+ await tx.commit();
398
+ return result;
399
+ }
400
+ catch (error) {
401
+ if (!undone) {
402
+ // A rollback that itself fails must not replace the error that caused it —
403
+ // that's how you end up debugging the wrong problem.
404
+ await tx.rollback().catch(() => { });
405
+ }
406
+ throw error;
407
+ }
408
+ }
409
+ /** Whether a transaction is open on this connection in the current context. */
410
+ export function inTransaction(connectionName) {
411
+ return openTransactions.getStore()?.has(connectionName ?? defaultConnection) ?? false;
412
+ }
263
413
  export function connection(name) {
264
414
  const source = resolve(name);
265
415
  return {
266
416
  table: (t) => new QueryBuilder(t, () => source),
267
- select: (sql, bindings = []) => source.conn.select(placeholders(sql, source.dialect), bindings),
268
- write: (sql, bindings = []) => source.conn.write(placeholders(sql, source.dialect), bindings),
417
+ select: (sql, bindings = []) => selectOn(source, sql, bindings),
418
+ write: (sql, bindings = []) => writeOn(source, sql, bindings),
269
419
  dialect: source.dialect,
270
420
  };
271
421
  }
422
+ /**
423
+ * The raw driver bridge and dialect for a connection (or the default) — what the
424
+ * `Migrator` needs. Unlike `connection()`, this hands back the unadjusted
425
+ * `Connection` so the migrator can apply its own placeholder conversion.
426
+ */
427
+ export function getConnection(name) {
428
+ const source = resolve(name);
429
+ return { connection: source.conn, dialect: source.dialect, name: source.name };
430
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Validate the environment **at boot**, not at 3am.
3
+ *
4
+ * // config/env.ts
5
+ * export const env = defineEnv({
6
+ * APP_KEY: envVar.string({ required: true }),
7
+ * PORT: envVar.number({ default: 3000 }),
8
+ * NODE_ENV: envVar.enum(["development", "test", "production"], { default: "development" }),
9
+ * DATABASE_URL: envVar.url({ required: true }),
10
+ * DEBUG: envVar.boolean({ default: false }),
11
+ * });
12
+ *
13
+ * env.PORT; // number — not "3000", and not string | undefined
14
+ * env.NODE_ENV; // "development" | "test" | "production"
15
+ *
16
+ * The plain `env("KEY")` helper hands back whatever is (or isn't) in
17
+ * `process.env` — so a missing `DATABASE_URL` boots a perfectly healthy-looking
18
+ * app that dies on the first request that needs it, in production, at night. This
19
+ * checks the whole environment up front and **refuses to start** otherwise.
20
+ *
21
+ * It reports **every** problem at once, not the first one. Fixing a deploy one
22
+ * missing variable per restart is its own small hell.
23
+ */
24
+ export type EnvType = "string" | "number" | "boolean" | "enum" | "url";
25
+ export interface EnvRule<T = unknown> {
26
+ readonly type: EnvType;
27
+ readonly required: boolean;
28
+ readonly default?: T;
29
+ readonly description?: string;
30
+ /** For `enum`. */
31
+ readonly values?: readonly string[];
32
+ /** Reject a value with a reason. */
33
+ readonly validate?: (value: T) => true | string;
34
+ /** @internal — carries the value type. */
35
+ readonly __value?: T;
36
+ }
37
+ interface RuleOptions<T> {
38
+ required?: boolean;
39
+ default?: T;
40
+ description?: string;
41
+ validate?: (value: T) => true | string;
42
+ }
43
+ /** Present unless it's explicitly optional with no default. */
44
+ type Present<O> = O extends {
45
+ required: true;
46
+ } ? true : O extends {
47
+ default: unknown;
48
+ } ? true : O extends {
49
+ required: false;
50
+ } ? false : false;
51
+ type Value<O, T> = Present<O> extends true ? T : T | undefined;
52
+ /** The rules a variable can be held to. */
53
+ export declare const envVar: {
54
+ string<O extends RuleOptions<string> = Record<never, never>>(options?: O & RuleOptions<string>): EnvRule<Value<O, string>>;
55
+ number<O extends RuleOptions<number> = Record<never, never>>(options?: O & RuleOptions<number>): EnvRule<Value<O, number>>;
56
+ /** `true`/`false`/`1`/`0`/`yes`/`no`. */
57
+ boolean<O extends RuleOptions<boolean> = Record<never, never>>(options?: O & RuleOptions<boolean>): EnvRule<Value<O, boolean>>;
58
+ /** One of a fixed set — the value is typed as the union, not `string`. */
59
+ enum<const V extends readonly string[], O extends RuleOptions<V[number]> = Record<never, never>>(values: V, options?: O & RuleOptions<V[number]>): EnvRule<Value<O, V[number]>>;
60
+ /** A string that must parse as a URL — catches a truncated connection string. */
61
+ url<O extends RuleOptions<string> = Record<never, never>>(options?: O & RuleOptions<string>): EnvRule<Value<O, string>>;
62
+ };
63
+ /**
64
+ * The shape a schema must have.
65
+ *
66
+ * `any` rather than `unknown` on purpose: `EnvRule<T>` has `validate(value: T)`,
67
+ * which puts `T` in a *parameter* position and so makes the type invariant — an
68
+ * `EnvRule<string>` isn't assignable to an `EnvRule<unknown>`. This is the
69
+ * standard escape hatch for a schema constraint; the *values* stay fully typed,
70
+ * which is what actually matters (see `EnvValues`).
71
+ */
72
+ export type EnvSchema = Record<string, EnvRule<any>>;
73
+ /** The values a schema produces — where `env.PORT: number` comes from. */
74
+ export type EnvValues<S extends EnvSchema> = {
75
+ readonly [K in keyof S]: S[K] extends {
76
+ __value?: infer V;
77
+ } ? V : never;
78
+ };
79
+ /** Thrown when the environment doesn't satisfy the schema. Lists every problem. */
80
+ export declare class EnvValidationError extends Error {
81
+ readonly problems: string[];
82
+ constructor(problems: string[]);
83
+ }
84
+ export interface DefineEnvOptions {
85
+ /** Where to read from. Default: `process.env`. */
86
+ source?: Record<string, string | undefined>;
87
+ }
88
+ /**
89
+ * Validate the environment against a schema and return the typed, frozen values.
90
+ * Throws `EnvValidationError` listing **every** problem if it doesn't hold.
91
+ *
92
+ * Call it at module scope in `config/env.ts` so an invalid environment fails the
93
+ * boot — loudly, immediately, and with the whole list.
94
+ */
95
+ export declare function defineEnv<const S extends EnvSchema>(schema: S, options?: DefineEnvOptions): EnvValues<S>;
96
+ export {};
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Validate the environment **at boot**, not at 3am.
3
+ *
4
+ * // config/env.ts
5
+ * export const env = defineEnv({
6
+ * APP_KEY: envVar.string({ required: true }),
7
+ * PORT: envVar.number({ default: 3000 }),
8
+ * NODE_ENV: envVar.enum(["development", "test", "production"], { default: "development" }),
9
+ * DATABASE_URL: envVar.url({ required: true }),
10
+ * DEBUG: envVar.boolean({ default: false }),
11
+ * });
12
+ *
13
+ * env.PORT; // number — not "3000", and not string | undefined
14
+ * env.NODE_ENV; // "development" | "test" | "production"
15
+ *
16
+ * The plain `env("KEY")` helper hands back whatever is (or isn't) in
17
+ * `process.env` — so a missing `DATABASE_URL` boots a perfectly healthy-looking
18
+ * app that dies on the first request that needs it, in production, at night. This
19
+ * checks the whole environment up front and **refuses to start** otherwise.
20
+ *
21
+ * It reports **every** problem at once, not the first one. Fixing a deploy one
22
+ * missing variable per restart is its own small hell.
23
+ */
24
+ /** The rules a variable can be held to. */
25
+ export const envVar = {
26
+ string(options) {
27
+ return { type: "string", required: false, ...options };
28
+ },
29
+ number(options) {
30
+ return { type: "number", required: false, ...options };
31
+ },
32
+ /** `true`/`false`/`1`/`0`/`yes`/`no`. */
33
+ boolean(options) {
34
+ return { type: "boolean", required: false, ...options };
35
+ },
36
+ /** One of a fixed set — the value is typed as the union, not `string`. */
37
+ enum(values, options) {
38
+ return { type: "enum", required: false, values, ...options };
39
+ },
40
+ /** A string that must parse as a URL — catches a truncated connection string. */
41
+ url(options) {
42
+ return { type: "url", required: false, ...options };
43
+ },
44
+ };
45
+ /* ------------------------------- validation ------------------------------- */
46
+ /** Thrown when the environment doesn't satisfy the schema. Lists every problem. */
47
+ export class EnvValidationError extends Error {
48
+ problems;
49
+ constructor(problems) {
50
+ super(`The environment is not valid:\n\n${problems.map((p) => ` • ${p}`).join("\n")}\n\n` +
51
+ `Set these in your .env (or your host's environment) and start again.`);
52
+ this.problems = problems;
53
+ this.name = "EnvValidationError";
54
+ }
55
+ }
56
+ const TRUE = new Set(["true", "1", "yes", "on"]);
57
+ const FALSE = new Set(["false", "0", "no", "off"]);
58
+ /** Coerce one raw string. Returns a problem string instead of throwing. */
59
+ function coerce(name, rule, raw) {
60
+ switch (rule.type) {
61
+ case "number": {
62
+ const value = Number(raw);
63
+ if (raw.trim() === "" || Number.isNaN(value)) {
64
+ return { problem: `${name} must be a number, got "${raw}".` };
65
+ }
66
+ return { value };
67
+ }
68
+ case "boolean": {
69
+ const lower = raw.toLowerCase();
70
+ if (TRUE.has(lower))
71
+ return { value: true };
72
+ if (FALSE.has(lower))
73
+ return { value: false };
74
+ return { problem: `${name} must be true or false, got "${raw}".` };
75
+ }
76
+ case "enum": {
77
+ if (!rule.values?.includes(raw)) {
78
+ return { problem: `${name} must be one of ${rule.values?.join(", ")}, got "${raw}".` };
79
+ }
80
+ return { value: raw };
81
+ }
82
+ case "url": {
83
+ try {
84
+ new URL(raw);
85
+ }
86
+ catch {
87
+ return { problem: `${name} must be a valid URL, got "${raw}".` };
88
+ }
89
+ return { value: raw };
90
+ }
91
+ default:
92
+ return { value: raw };
93
+ }
94
+ }
95
+ /**
96
+ * Validate the environment against a schema and return the typed, frozen values.
97
+ * Throws `EnvValidationError` listing **every** problem if it doesn't hold.
98
+ *
99
+ * Call it at module scope in `config/env.ts` so an invalid environment fails the
100
+ * boot — loudly, immediately, and with the whole list.
101
+ */
102
+ export function defineEnv(schema, options = {}) {
103
+ const source = options.source ?? process.env;
104
+ const values = {};
105
+ const problems = [];
106
+ for (const [name, rule] of Object.entries(schema)) {
107
+ const raw = source[name];
108
+ // Absent, or set to the empty string — which is almost always a mistake
109
+ // rather than a deliberate "".
110
+ if (raw === undefined || raw === "") {
111
+ if (rule.default !== undefined) {
112
+ values[name] = rule.default;
113
+ continue;
114
+ }
115
+ if (rule.required) {
116
+ const hint = rule.description ? ` (${rule.description})` : "";
117
+ problems.push(`${name} is required but not set${hint}.`);
118
+ continue;
119
+ }
120
+ values[name] = undefined;
121
+ continue;
122
+ }
123
+ const result = coerce(name, rule, raw);
124
+ if ("problem" in result) {
125
+ problems.push(result.problem);
126
+ continue;
127
+ }
128
+ const problem = rule.validate?.(result.value);
129
+ if (problem !== undefined && problem !== true) {
130
+ problems.push(`${name}: ${problem}`);
131
+ continue;
132
+ }
133
+ values[name] = result.value;
134
+ }
135
+ // Every problem at once — fixing a deploy one missing variable per restart is
136
+ // its own small hell.
137
+ if (problems.length)
138
+ throw new EnvValidationError(problems);
139
+ return Object.freeze(values);
140
+ }
@@ -14,8 +14,8 @@
14
14
  * this.use(healthCheck()); // serves both endpoints
15
15
  *
16
16
  * Keel ships the checks that mean something wherever it runs — a database, Redis,
17
- * the cache. Deliberately absent are AdonisJS's disk-space and heap/RSS checks:
18
- * they measure a Node process, and on Workers there isn't one.
17
+ * the cache. Deliberately absent are disk-space and heap/RSS checks: they measure
18
+ * a Node process, and on Workers there isn't one.
19
19
  */
20
20
  import type { MiddlewareHandler } from "hono";
21
21
  export type HealthStatus = "ok" | "warning" | "error";
@@ -14,8 +14,8 @@
14
14
  * this.use(healthCheck()); // serves both endpoints
15
15
  *
16
16
  * Keel ships the checks that mean something wherever it runs — a database, Redis,
17
- * the cache. Deliberately absent are AdonisJS's disk-space and heap/RSS checks:
18
- * they measure a Node process, and on Workers there isn't one.
17
+ * the cache. Deliberately absent are disk-space and heap/RSS checks: they measure
18
+ * a Node process, and on Workers there isn't one.
19
19
  */
20
20
  import { connection } from "./database.js";
21
21
  import { redis } from "./redis.js";
@@ -21,6 +21,8 @@ export declare class HttpKernel {
21
21
  build(): Hono;
22
22
  /** Compile a set of routes onto a fresh Hono instance. */
23
23
  private compile;
24
+ /** Emit `request.handled` with method, path, status, timing, and headers. */
25
+ private reportRequest;
24
26
  private handle;
25
27
  /** Default rendering: HTML for browsers, JSON otherwise; details in debug. */
26
28
  protected renderException(err: unknown, c: Context): Response;
@@ -8,6 +8,7 @@ import { contextStorage } from "hono/context-storage";
8
8
  import { Config } from "../config.js";
9
9
  import { HttpException, NotFoundException, ValidationException, STATUS_TEXT, } from "../exceptions.js";
10
10
  import { Router } from "./router.js";
11
+ import { instrument, runRequest, newRequestId, currentRequestId } from "../instrumentation.js";
11
12
  /** Per-request stash of subdomain params, keyed by the raw Request. */
12
13
  const SUBDOMAINS = new WeakMap();
13
14
  /** Compile a host pattern like ":tenant.example.com" into a matcher. */
@@ -96,6 +97,26 @@ export class HttpKernel {
96
97
  c.set("subdomains", subs);
97
98
  await next();
98
99
  });
100
+ // Open a request scope: a request id everything downstream (queries, logs,
101
+ // jobs) can attribute itself to, plus timing. Emitting `request.handled` and
102
+ // `exception` from here — inside the scope — is what lets a watcher tie a
103
+ // request to everything that happened within it. A no-op stream when nobody
104
+ // is listening, so this stays cheap for apps that don't instrument.
105
+ hono.use("*", async (c, next) => {
106
+ const id = newRequestId();
107
+ const start = Date.now();
108
+ try {
109
+ await runRequest(id, () => next());
110
+ }
111
+ catch (err) {
112
+ // Errors thrown *outside* Hono's per-request handling (rare) land here;
113
+ // the common case (a handler throwing) is caught by Hono and reaches
114
+ // `handle()` instead, which is where `exception` is emitted.
115
+ this.reportRequest(c, id, start, err instanceof HttpException ? err.status : 500);
116
+ throw err;
117
+ }
118
+ this.reportRequest(c, id, start, c.res.status);
119
+ });
99
120
  for (const mw of this.middleware) {
100
121
  hono.use("*", mw);
101
122
  }
@@ -128,7 +149,34 @@ export class HttpKernel {
128
149
  hono.onError((err, c) => this.handle(err, c));
129
150
  return hono;
130
151
  }
152
+ /** Emit `request.handled` with method, path, status, timing, and headers. */
153
+ reportRequest(c, id, start, status) {
154
+ const url = new URL(c.req.url);
155
+ const headers = {};
156
+ c.req.raw.headers.forEach((v, k) => (headers[k] = v));
157
+ const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
158
+ instrument("request.handled", {
159
+ id,
160
+ method: c.req.method,
161
+ path: url.pathname,
162
+ status,
163
+ durationMs: Date.now() - start,
164
+ headers,
165
+ ...(ip ? { ip } : {}),
166
+ });
167
+ }
131
168
  async handle(err, c) {
169
+ // The single choke point every error (thrown handler or unmatched route)
170
+ // passes through — and it runs inside the request scope, so `currentRequestId`
171
+ // ties the exception to its request.
172
+ const requestId = currentRequestId();
173
+ instrument("exception", {
174
+ error: err,
175
+ method: c.req.method,
176
+ path: new URL(c.req.url).pathname,
177
+ status: err instanceof HttpException ? err.status : 500,
178
+ ...(requestId ? { requestId } : {}),
179
+ });
132
180
  // Reportable exceptions: give them a chance to log/report themselves.
133
181
  const maybe = err;
134
182
  if (typeof maybe?.report === "function") {
@@ -2,8 +2,8 @@
2
2
  * Router facade. Collects route definitions declaratively; the HTTP kernel
3
3
  * compiles them onto the underlying Hono instance at boot.
4
4
  *
5
- * Fluent, AdonisJS-inspired API: named routes, per-route and group middleware,
6
- * prefixes, param constraints, resource routes, and URL generation.
5
+ * A fluent API: named routes, per-route and group middleware, prefixes, param
6
+ * constraints, resource routes, and URL generation.
7
7
  */
8
8
  import type { Context as HonoContext, MiddlewareHandler } from "hono";
9
9
  import type { Container, Constructor } from "../container.js";
@@ -66,7 +66,7 @@ export declare class Route {
66
66
  as(name: string): this;
67
67
  /** Attach middleware that runs only for this route (after group middleware). */
68
68
  middleware(mw: MiddlewareRef | MiddlewareRef[]): this;
69
- /** Alias for middleware(), matching AdonisJS. */
69
+ /** Alias for middleware(). */
70
70
  use(mw: MiddlewareRef | MiddlewareRef[]): this;
71
71
  /** Constrain a route parameter with a regex, source string, or matcher. */
72
72
  where(param: string, matcher: Matcher): this;
@@ -81,7 +81,7 @@ export declare class RouteGroup {
81
81
  constructor(routes: RouteDefinition[]);
82
82
  prefix(prefix: string): this;
83
83
  middleware(mw: MiddlewareRef | MiddlewareRef[]): this;
84
- /** Alias for middleware(), matching AdonisJS. */
84
+ /** Alias for middleware(). */
85
85
  use(mw: MiddlewareRef | MiddlewareRef[]): this;
86
86
  /** Attach metadata to every route in the group (a route's own config wins). */
87
87
  config(data: Record<string, unknown>): this;
@@ -115,7 +115,7 @@ declare class RouteMatcher {
115
115
  constructor(router: Router, path: string);
116
116
  /** Redirect to a path or URL. */
117
117
  redirect(to: string, status?: number): Route;
118
- /** Alias for redirect(), matching AdonisJS. */
118
+ /** Alias for redirect(). */
119
119
  redirectToPath(to: string, status?: number): Route;
120
120
  /** Redirect to a named route, optionally with params and a query string. */
121
121
  redirectToRoute(name: string, params?: Record<string, string | number>, options?: {
@@ -2,8 +2,8 @@
2
2
  * Router facade. Collects route definitions declaratively; the HTTP kernel
3
3
  * compiles them onto the underlying Hono instance at boot.
4
4
  *
5
- * Fluent, AdonisJS-inspired API: named routes, per-route and group middleware,
6
- * prefixes, param constraints, resource routes, and URL generation.
5
+ * A fluent API: named routes, per-route and group middleware, prefixes, param
6
+ * constraints, resource routes, and URL generation.
7
7
  */
8
8
  import { view, config } from "../helpers.js";
9
9
  import { redirect as makeRedirect, request } from "../request.js";
@@ -56,7 +56,7 @@ export class Route {
56
56
  this.def.middleware.push(...(Array.isArray(mw) ? mw : [mw]));
57
57
  return this;
58
58
  }
59
- /** Alias for middleware(), matching AdonisJS. */
59
+ /** Alias for middleware(). */
60
60
  use(mw) {
61
61
  return this.middleware(mw);
62
62
  }
@@ -94,7 +94,7 @@ export class RouteGroup {
94
94
  r.middleware.unshift(...list); // group runs first
95
95
  return this;
96
96
  }
97
- /** Alias for middleware(), matching AdonisJS. */
97
+ /** Alias for middleware(). */
98
98
  use(mw) {
99
99
  return this.middleware(mw);
100
100
  }
@@ -200,7 +200,7 @@ class RouteMatcher {
200
200
  redirect(to, status = 302) {
201
201
  return this.router.get(this.path, () => makeRedirect(to, status));
202
202
  }
203
- /** Alias for redirect(), matching AdonisJS. */
203
+ /** Alias for redirect(). */
204
204
  redirectToPath(to, status = 302) {
205
205
  return this.redirect(to, status);
206
206
  }