@shaferllc/keel 0.66.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 (239) hide show
  1. package/AGENTS.md +169 -0
  2. package/README.md +44 -6
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/api/api.config.stub +9 -0
  5. package/dist/api/config.d.ts +13 -0
  6. package/dist/api/config.js +14 -0
  7. package/dist/api/index.d.ts +16 -0
  8. package/dist/api/index.js +13 -0
  9. package/dist/api/provider.d.ts +10 -0
  10. package/dist/api/provider.js +17 -0
  11. package/dist/api/query.d.ts +35 -0
  12. package/dist/api/query.js +42 -0
  13. package/dist/api/resource.d.ts +91 -0
  14. package/dist/api/resource.js +188 -0
  15. package/dist/core/application.d.ts +5 -5
  16. package/dist/core/application.js +8 -2
  17. package/dist/core/cache.d.ts +81 -5
  18. package/dist/core/cache.js +188 -23
  19. package/dist/core/cli/stubs.d.ts +26 -0
  20. package/dist/core/cli/stubs.js +225 -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/events.d.ts +129 -5
  32. package/dist/core/events.js +165 -7
  33. package/dist/core/health.d.ts +141 -0
  34. package/dist/core/health.js +226 -0
  35. package/dist/core/helpers.d.ts +9 -3
  36. package/dist/core/helpers.js +11 -3
  37. package/dist/core/http/kernel.d.ts +2 -0
  38. package/dist/core/http/kernel.js +48 -0
  39. package/dist/core/http/router.d.ts +5 -5
  40. package/dist/core/http/router.js +5 -5
  41. package/dist/core/i18n.d.ts +162 -0
  42. package/dist/core/i18n.js +472 -0
  43. package/dist/core/index.d.ts +39 -13
  44. package/dist/core/index.js +19 -6
  45. package/dist/core/instrumentation.d.ts +113 -0
  46. package/dist/core/instrumentation.js +52 -0
  47. package/dist/core/lock.d.ts +139 -0
  48. package/dist/core/lock.js +215 -0
  49. package/dist/core/logger.d.ts +89 -4
  50. package/dist/core/logger.js +167 -22
  51. package/dist/core/mail.d.ts +128 -7
  52. package/dist/core/mail.js +264 -16
  53. package/dist/core/notification.js +10 -1
  54. package/dist/core/package.d.ts +120 -0
  55. package/dist/core/package.js +169 -0
  56. package/dist/core/pages.d.ts +108 -0
  57. package/dist/core/pages.js +199 -0
  58. package/dist/core/queue.d.ts +134 -9
  59. package/dist/core/queue.js +325 -14
  60. package/dist/core/repl.d.ts +33 -0
  61. package/dist/core/repl.js +88 -0
  62. package/dist/core/scheduler.js +6 -0
  63. package/dist/core/social.d.ts +4 -4
  64. package/dist/core/social.js +4 -4
  65. package/dist/core/storage.d.ts +159 -6
  66. package/dist/core/storage.js +299 -7
  67. package/dist/core/telemetry.d.ts +208 -0
  68. package/dist/core/telemetry.js +383 -0
  69. package/dist/core/template.d.ts +2 -3
  70. package/dist/core/template.js +2 -3
  71. package/dist/core/testing.d.ts +170 -1
  72. package/dist/core/testing.js +504 -2
  73. package/dist/db/d1.js +13 -0
  74. package/dist/db/pg.d.ts +13 -0
  75. package/dist/db/pg.js +46 -4
  76. package/dist/mcp/server.d.ts +19 -0
  77. package/dist/mcp/server.js +355 -0
  78. package/dist/openapi/config.d.ts +28 -0
  79. package/dist/openapi/config.js +25 -0
  80. package/dist/openapi/doc.d.ts +40 -0
  81. package/dist/openapi/doc.js +20 -0
  82. package/dist/openapi/export.d.ts +8 -0
  83. package/dist/openapi/export.js +19 -0
  84. package/dist/openapi/gate.d.ts +15 -0
  85. package/dist/openapi/gate.js +27 -0
  86. package/dist/openapi/index.d.ts +19 -0
  87. package/dist/openapi/index.js +15 -0
  88. package/dist/openapi/openapi.config.stub +29 -0
  89. package/dist/openapi/provider.d.ts +18 -0
  90. package/dist/openapi/provider.js +35 -0
  91. package/dist/openapi/routes.d.ts +9 -0
  92. package/dist/openapi/routes.js +23 -0
  93. package/dist/openapi/spec.d.ts +23 -0
  94. package/dist/openapi/spec.js +132 -0
  95. package/dist/openapi/ui.d.ts +8 -0
  96. package/dist/openapi/ui.js +31 -0
  97. package/dist/openapi/zod.d.ts +12 -0
  98. package/dist/openapi/zod.js +46 -0
  99. package/dist/watch/config.d.ts +33 -0
  100. package/dist/watch/config.js +38 -0
  101. package/dist/watch/entry.d.ts +53 -0
  102. package/dist/watch/entry.js +105 -0
  103. package/dist/watch/gate.d.ts +20 -0
  104. package/dist/watch/gate.js +32 -0
  105. package/dist/watch/index.d.ts +21 -0
  106. package/dist/watch/index.js +17 -0
  107. package/dist/watch/migration.d.ts +7 -0
  108. package/dist/watch/migration.js +20 -0
  109. package/dist/watch/provider.d.ts +22 -0
  110. package/dist/watch/provider.js +58 -0
  111. package/dist/watch/prune.d.ts +11 -0
  112. package/dist/watch/prune.js +20 -0
  113. package/dist/watch/recorder.d.ts +24 -0
  114. package/dist/watch/recorder.js +39 -0
  115. package/dist/watch/routes.d.ts +13 -0
  116. package/dist/watch/routes.js +55 -0
  117. package/dist/watch/store.d.ts +54 -0
  118. package/dist/watch/store.js +158 -0
  119. package/dist/watch/ui/dist/watch.css +1 -0
  120. package/dist/watch/ui/dist/watch.js +555 -0
  121. package/dist/watch/ui-shell.d.ts +12 -0
  122. package/dist/watch/ui-shell.js +24 -0
  123. package/dist/watch/watch.config.stub +47 -0
  124. package/dist/watch/watchers.d.ts +12 -0
  125. package/dist/watch/watchers.js +156 -0
  126. package/docs/ai-manifest.json +3206 -0
  127. package/docs/ai.md +128 -0
  128. package/docs/api-resources.md +118 -0
  129. package/docs/architecture.md +331 -0
  130. package/docs/authentication.md +453 -0
  131. package/docs/authorization.md +167 -0
  132. package/docs/broadcasting.md +137 -0
  133. package/docs/broker.md +500 -0
  134. package/docs/cache.md +558 -0
  135. package/docs/configuration.md +385 -0
  136. package/docs/console.md +536 -0
  137. package/docs/container.md +467 -0
  138. package/docs/controllers.md +265 -0
  139. package/docs/cors.md +51 -0
  140. package/docs/database.md +631 -0
  141. package/docs/debugging.md +129 -0
  142. package/docs/decorators.md +127 -0
  143. package/docs/errors.md +395 -0
  144. package/docs/events.md +496 -0
  145. package/docs/examples/architecture-app.ts +27 -0
  146. package/docs/examples/authentication.ts +61 -0
  147. package/docs/examples/authorization.ts +79 -0
  148. package/docs/examples/broadcasting.ts +60 -0
  149. package/docs/examples/broker-cache-validate.ts +34 -0
  150. package/docs/examples/broker-fault-tolerance.ts +29 -0
  151. package/docs/examples/broker-middleware.ts +27 -0
  152. package/docs/examples/broker.ts +203 -0
  153. package/docs/examples/cache.ts +222 -0
  154. package/docs/examples/configuration.ts +121 -0
  155. package/docs/examples/console.ts +134 -0
  156. package/docs/examples/container.ts +134 -0
  157. package/docs/examples/controllers.ts +86 -0
  158. package/docs/examples/database.ts +208 -0
  159. package/docs/examples/debugging.ts +41 -0
  160. package/docs/examples/decorators.ts +40 -0
  161. package/docs/examples/errors.ts +121 -0
  162. package/docs/examples/events.ts +204 -0
  163. package/docs/examples/factories.ts +84 -0
  164. package/docs/examples/hashing.ts +71 -0
  165. package/docs/examples/health.ts +94 -0
  166. package/docs/examples/helpers.ts +171 -0
  167. package/docs/examples/hooks.ts +54 -0
  168. package/docs/examples/i18n.ts +117 -0
  169. package/docs/examples/inertia.ts +81 -0
  170. package/docs/examples/locks.ts +120 -0
  171. package/docs/examples/logger.ts +166 -0
  172. package/docs/examples/mail.ts +263 -0
  173. package/docs/examples/middleware.ts +119 -0
  174. package/docs/examples/migrations.ts +126 -0
  175. package/docs/examples/models.ts +239 -0
  176. package/docs/examples/notification.ts +124 -0
  177. package/docs/examples/pages.ts +82 -0
  178. package/docs/examples/providers.ts +123 -0
  179. package/docs/examples/queues.ts +254 -0
  180. package/docs/examples/rate-limiting.ts +42 -0
  181. package/docs/examples/redis.ts +99 -0
  182. package/docs/examples/request-response.ts +197 -0
  183. package/docs/examples/routing.ts +186 -0
  184. package/docs/examples/scheduling.ts +62 -0
  185. package/docs/examples/sessions.ts +102 -0
  186. package/docs/examples/static-files.ts +63 -0
  187. package/docs/examples/storage.ts +132 -0
  188. package/docs/examples/telemetry.ts +127 -0
  189. package/docs/examples/templates.ts +58 -0
  190. package/docs/examples/testing.ts +215 -0
  191. package/docs/examples/transformer.ts +141 -0
  192. package/docs/examples/transformers.ts +49 -0
  193. package/docs/examples/url-builder.ts +86 -0
  194. package/docs/examples/validation.ts +102 -0
  195. package/docs/examples/views.tsx +62 -0
  196. package/docs/examples/vite.ts +106 -0
  197. package/docs/factories.md +166 -0
  198. package/docs/getting-started.md +290 -0
  199. package/docs/hashing.md +259 -0
  200. package/docs/health.md +225 -0
  201. package/docs/helpers.md +347 -0
  202. package/docs/hono.md +186 -0
  203. package/docs/hooks.md +118 -0
  204. package/docs/i18n.md +302 -0
  205. package/docs/inertia.md +241 -0
  206. package/docs/locks.md +323 -0
  207. package/docs/logger.md +436 -0
  208. package/docs/mail.md +751 -0
  209. package/docs/middleware.md +425 -0
  210. package/docs/migrations.md +476 -0
  211. package/docs/models.md +810 -0
  212. package/docs/notifications.md +474 -0
  213. package/docs/openapi.md +111 -0
  214. package/docs/packages.md +118 -0
  215. package/docs/pages.md +217 -0
  216. package/docs/providers.md +363 -0
  217. package/docs/queues.md +679 -0
  218. package/docs/rate-limiting.md +155 -0
  219. package/docs/redis.md +178 -0
  220. package/docs/request-response.md +953 -0
  221. package/docs/routing.md +804 -0
  222. package/docs/scheduling.md +110 -0
  223. package/docs/security.md +85 -0
  224. package/docs/sessions.md +354 -0
  225. package/docs/social-auth.md +174 -0
  226. package/docs/static-files.md +211 -0
  227. package/docs/storage.md +452 -0
  228. package/docs/telemetry.md +263 -0
  229. package/docs/templates.md +314 -0
  230. package/docs/testing.md +376 -0
  231. package/docs/transformers.md +381 -0
  232. package/docs/url-builder.md +295 -0
  233. package/docs/validation.md +288 -0
  234. package/docs/views.md +267 -0
  235. package/docs/vite.md +434 -0
  236. package/docs/watch.md +118 -0
  237. package/llms-full.txt +19816 -0
  238. package/llms.txt +127 -0
  239. package/package.json +42 -7
@@ -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
+ }
@@ -5,17 +5,141 @@
5
5
  *
6
6
  * listen("user.registered", (user) => sendWelcome(user));
7
7
  * await emit("user.registered", user);
8
+ *
9
+ * Declare an event in `EventsList` and the emitter checks both sides of it —
10
+ * the payload you fire and the payload your listener receives:
11
+ *
12
+ * declare module "@shaferllc/keel/core" {
13
+ * interface EventsList {
14
+ * "order.paid": { id: number; total: number };
15
+ * }
16
+ * }
17
+ *
18
+ * One listener's failure never stops another's: `emit` runs them all, then
19
+ * reports what broke — to `onError()` if you registered one, otherwise by
20
+ * rejecting.
21
+ */
22
+ /**
23
+ * The registry of known events, keyed by name. Empty by default — augment it
24
+ * from your app to type an event's payload:
25
+ *
26
+ * declare module "@shaferllc/keel/core" {
27
+ * interface EventsList {
28
+ * "user.registered": User;
29
+ * }
30
+ * }
31
+ */
32
+ export interface EventsList {
33
+ }
34
+ /** A declared event name, or any other string. */
35
+ export type EventName = keyof EventsList | (string & {});
36
+ /**
37
+ * The declared payload for an event, or `unknown` for an undeclared one.
38
+ *
39
+ * The `[E]` tuple wrapper stops the conditional from distributing: `EventName`
40
+ * is itself a union, and a naked `E` would resolve to a *union* of every
41
+ * declared payload rather than the one event's.
42
+ */
43
+ export type PayloadOf<E> = [E] extends [keyof EventsList] ? EventsList[E & keyof EventsList] : unknown;
44
+ /**
45
+ * `emit`'s payload argument. Declared events must be fired with their payload;
46
+ * undeclared (and void) ones may be fired without.
8
47
  */
48
+ export type EmitArgs<P> = [P] extends [void | undefined] ? [payload?: P] : unknown extends P ? [payload?: P] : [payload: P];
49
+ /**
50
+ * The payload type for an event.
51
+ *
52
+ * A **declared** event takes its payload from `EventsList`, and `NoInfer` is
53
+ * load-bearing there: without it TypeScript would infer the payload type from
54
+ * the value you pass, so `emit("order.paid", anythingAtAll)` would type-check by
55
+ * inferring it to whatever you handed over — and the registry would enforce
56
+ * nothing.
57
+ *
58
+ * An **undeclared** event behaves as it always has: the payload type is inferred
59
+ * from the listener, or given explicitly as `listen<Order>("order.paid", …)`,
60
+ * and falls back to `unknown`.
61
+ */
62
+ export type Resolve<T, E> = [E] extends [keyof EventsList] ? NoInfer<EventsList[E & keyof EventsList]> : T;
9
63
  export type Listener<T = unknown> = (payload: T) => void | Promise<void>;
64
+ /** A listener for *every* event, as registered by `onAny`. */
65
+ export type AnyListener = (event: string, payload: unknown) => void | Promise<void>;
66
+ /** Called when a listener throws, instead of `emit` rejecting. */
67
+ export type ErrorHandler = (event: string, error: unknown, payload: unknown) => void | Promise<void>;
68
+ /** One recorded emission, as captured by a fake. */
69
+ export interface RecordedEvent {
70
+ event: string;
71
+ payload: unknown;
72
+ }
10
73
  export declare class Events {
11
74
  private listeners;
75
+ private anyListeners;
76
+ private errorHandler?;
77
+ /** Non-null while faking: the events to intercept (`true` = all of them). */
78
+ private faked?;
79
+ private buffer?;
12
80
  /** Subscribe to an event. Returns an unsubscribe function. */
13
- on<T = unknown>(event: string, listener: Listener<T>): () => void;
81
+ on<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): () => void;
14
82
  /** Subscribe for a single emission. */
15
- once<T = unknown>(event: string, listener: Listener<T>): () => void;
16
- off<T = unknown>(event: string, listener: Listener<T>): void;
17
- /** Emit an event, awaiting every listener in order. */
18
- emit<T = unknown>(event: string, payload?: T): Promise<void>;
83
+ once<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): () => void;
84
+ off<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): void;
85
+ /**
86
+ * Subscribe to *every* event for logging and other cross-cutting concerns.
87
+ * These run before the event's own listeners. Returns an unsubscribe function.
88
+ */
89
+ onAny(listener: AnyListener): () => void;
90
+ /**
91
+ * Handle listener failures instead of letting `emit` reject. Without one, an
92
+ * `emit` whose listeners threw rejects (with an `AggregateError` if more than
93
+ * one did) — errors are never silently swallowed.
94
+ */
95
+ onError(handler: ErrorHandler): void;
96
+ /**
97
+ * Emit an event, awaiting every listener in registration order.
98
+ *
99
+ * A listener that throws does not stop the others — they all run, and the
100
+ * failures are reported afterwards. That's the point of an emitter: one
101
+ * subscriber's bug shouldn't silently cancel an unrelated subscriber's work.
102
+ */
103
+ emit<T = unknown, E extends EventName = EventName>(event: E, ...args: EmitArgs<Resolve<T, E>>): Promise<void>;
19
104
  listenerCount(event: string): number;
20
105
  clear(event?: string): void;
106
+ /** Drop every `onAny` listener and the error handler too. */
107
+ clearAll(): void;
108
+ /**
109
+ * Record emissions instead of running listeners, so a test can assert an event
110
+ * fired without triggering its side effects. Undo with `restore()`.
111
+ *
112
+ * const events = events().fake();
113
+ * await register(user);
114
+ * events.assertEmitted("user.registered");
115
+ *
116
+ * Pass one or more event names to fake only those — everything else dispatches
117
+ * for real.
118
+ */
119
+ fake(only?: EventName | EventName[]): EventBuffer;
120
+ /** Stop faking; listeners run for real again. */
121
+ restore(): void;
122
+ }
123
+ /** What a fake records, plus assertions over it. */
124
+ export declare class EventBuffer {
125
+ private events;
126
+ /** @internal — called by `Events.emit` while faking. */
127
+ record(event: string, payload: unknown): void;
128
+ /** Every emission recorded so far, in order. */
129
+ all(): RecordedEvent[];
130
+ /** The payloads recorded for one event. */
131
+ payloadsFor<E extends EventName>(event: E): PayloadOf<E>[];
132
+ /**
133
+ * Assert the event fired — optionally only counting emissions whose payload
134
+ * satisfies `predicate`.
135
+ *
136
+ * buffer.assertEmitted("order.paid", (o) => o.total === 4200);
137
+ */
138
+ assertEmitted<E extends EventName>(event: E, predicate?: (payload: PayloadOf<E>) => boolean): void;
139
+ assertNotEmitted<E extends EventName>(event: E): void;
140
+ assertEmittedCount<E extends EventName>(event: E, expected: number): void;
141
+ /** Assert nothing at all was emitted. */
142
+ assertNoneEmitted(): void;
143
+ private listenerCountFor;
144
+ private summary;
21
145
  }