@shaferllc/keel 0.59.0 → 0.68.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 (166) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +14 -2
  6. package/dist/core/auth.d.ts +47 -0
  7. package/dist/core/auth.js +77 -0
  8. package/dist/core/authorization.d.ts +9 -1
  9. package/dist/core/authorization.js +22 -2
  10. package/dist/core/cache.d.ts +82 -5
  11. package/dist/core/cache.js +181 -23
  12. package/dist/core/cli/stubs.d.ts +12 -0
  13. package/dist/core/cli/stubs.js +120 -0
  14. package/dist/core/container.d.ts +20 -0
  15. package/dist/core/container.js +52 -0
  16. package/dist/core/cors.d.ts +29 -0
  17. package/dist/core/cors.js +72 -0
  18. package/dist/core/crypto.d.ts +40 -4
  19. package/dist/core/crypto.js +66 -6
  20. package/dist/core/csrf.d.ts +25 -0
  21. package/dist/core/csrf.js +78 -0
  22. package/dist/core/database.d.ts +49 -4
  23. package/dist/core/database.js +89 -21
  24. package/dist/core/events.d.ts +129 -5
  25. package/dist/core/events.js +165 -7
  26. package/dist/core/health.d.ts +141 -0
  27. package/dist/core/health.js +226 -0
  28. package/dist/core/helpers.d.ts +15 -3
  29. package/dist/core/helpers.js +23 -3
  30. package/dist/core/index.d.ts +33 -18
  31. package/dist/core/index.js +16 -8
  32. package/dist/core/lock.d.ts +139 -0
  33. package/dist/core/lock.js +215 -0
  34. package/dist/core/logger.d.ts +82 -4
  35. package/dist/core/logger.js +141 -23
  36. package/dist/core/mail.d.ts +128 -7
  37. package/dist/core/mail.js +264 -16
  38. package/dist/core/model.d.ts +2 -0
  39. package/dist/core/model.js +16 -14
  40. package/dist/core/provider.d.ts +7 -0
  41. package/dist/core/provider.js +7 -0
  42. package/dist/core/queue.d.ts +134 -9
  43. package/dist/core/queue.js +304 -14
  44. package/dist/core/rate-limit.js +3 -0
  45. package/dist/core/relations.js +13 -13
  46. package/dist/core/request.d.ts +26 -0
  47. package/dist/core/request.js +77 -0
  48. package/dist/core/shield.d.ts +39 -0
  49. package/dist/core/shield.js +60 -0
  50. package/dist/core/social.d.ts +173 -0
  51. package/dist/core/social.js +337 -0
  52. package/dist/core/storage.d.ts +159 -6
  53. package/dist/core/storage.js +287 -7
  54. package/dist/core/tokens.d.ts +74 -0
  55. package/dist/core/tokens.js +155 -0
  56. package/dist/db/d1.d.ts +32 -0
  57. package/dist/db/d1.js +26 -0
  58. package/dist/db/libsql.d.ts +29 -0
  59. package/dist/db/libsql.js +32 -0
  60. package/dist/db/pg.d.ts +29 -0
  61. package/dist/db/pg.js +33 -0
  62. package/dist/mcp/server.d.ts +19 -0
  63. package/dist/mcp/server.js +355 -0
  64. package/docs/ai-manifest.json +2472 -0
  65. package/docs/ai.md +128 -0
  66. package/docs/architecture.md +331 -0
  67. package/docs/authentication.md +453 -0
  68. package/docs/authorization.md +167 -0
  69. package/docs/broadcasting.md +137 -0
  70. package/docs/broker.md +500 -0
  71. package/docs/cache.md +558 -0
  72. package/docs/configuration.md +311 -0
  73. package/docs/console.md +356 -0
  74. package/docs/container.md +467 -0
  75. package/docs/controllers.md +265 -0
  76. package/docs/cors.md +51 -0
  77. package/docs/database.md +530 -0
  78. package/docs/debugging.md +129 -0
  79. package/docs/decorators.md +127 -0
  80. package/docs/errors.md +395 -0
  81. package/docs/events.md +496 -0
  82. package/docs/examples/architecture-app.ts +27 -0
  83. package/docs/examples/authentication.ts +61 -0
  84. package/docs/examples/authorization.ts +79 -0
  85. package/docs/examples/broadcasting.ts +60 -0
  86. package/docs/examples/broker-cache-validate.ts +34 -0
  87. package/docs/examples/broker-fault-tolerance.ts +29 -0
  88. package/docs/examples/broker-middleware.ts +27 -0
  89. package/docs/examples/broker.ts +203 -0
  90. package/docs/examples/cache.ts +222 -0
  91. package/docs/examples/configuration.ts +81 -0
  92. package/docs/examples/container.ts +134 -0
  93. package/docs/examples/controllers.ts +86 -0
  94. package/docs/examples/database.ts +118 -0
  95. package/docs/examples/debugging.ts +41 -0
  96. package/docs/examples/decorators.ts +40 -0
  97. package/docs/examples/errors.ts +121 -0
  98. package/docs/examples/events.ts +204 -0
  99. package/docs/examples/factories.ts +84 -0
  100. package/docs/examples/hashing.ts +71 -0
  101. package/docs/examples/health.ts +94 -0
  102. package/docs/examples/helpers.ts +171 -0
  103. package/docs/examples/hooks.ts +54 -0
  104. package/docs/examples/inertia.ts +81 -0
  105. package/docs/examples/locks.ts +120 -0
  106. package/docs/examples/logger.ts +92 -0
  107. package/docs/examples/mail.ts +160 -0
  108. package/docs/examples/middleware.ts +119 -0
  109. package/docs/examples/migrations.ts +126 -0
  110. package/docs/examples/models.ts +239 -0
  111. package/docs/examples/notification.ts +124 -0
  112. package/docs/examples/providers.ts +123 -0
  113. package/docs/examples/queues.ts +254 -0
  114. package/docs/examples/rate-limiting.ts +42 -0
  115. package/docs/examples/redis.ts +99 -0
  116. package/docs/examples/request-response.ts +197 -0
  117. package/docs/examples/routing.ts +186 -0
  118. package/docs/examples/scheduling.ts +62 -0
  119. package/docs/examples/sessions.ts +102 -0
  120. package/docs/examples/static-files.ts +63 -0
  121. package/docs/examples/storage.ts +132 -0
  122. package/docs/examples/templates.ts +58 -0
  123. package/docs/examples/testing.ts +66 -0
  124. package/docs/examples/transformer.ts +141 -0
  125. package/docs/examples/transformers.ts +49 -0
  126. package/docs/examples/url-builder.ts +86 -0
  127. package/docs/examples/validation.ts +102 -0
  128. package/docs/examples/views.tsx +62 -0
  129. package/docs/examples/vite.ts +106 -0
  130. package/docs/factories.md +166 -0
  131. package/docs/getting-started.md +290 -0
  132. package/docs/hashing.md +259 -0
  133. package/docs/health.md +225 -0
  134. package/docs/helpers.md +347 -0
  135. package/docs/hono.md +186 -0
  136. package/docs/hooks.md +118 -0
  137. package/docs/inertia.md +241 -0
  138. package/docs/locks.md +323 -0
  139. package/docs/logger.md +290 -0
  140. package/docs/mail.md +678 -0
  141. package/docs/middleware.md +425 -0
  142. package/docs/migrations.md +476 -0
  143. package/docs/models.md +810 -0
  144. package/docs/notifications.md +474 -0
  145. package/docs/providers.md +363 -0
  146. package/docs/queues.md +679 -0
  147. package/docs/rate-limiting.md +155 -0
  148. package/docs/redis.md +178 -0
  149. package/docs/request-response.md +953 -0
  150. package/docs/routing.md +804 -0
  151. package/docs/scheduling.md +110 -0
  152. package/docs/security.md +85 -0
  153. package/docs/sessions.md +354 -0
  154. package/docs/social-auth.md +174 -0
  155. package/docs/static-files.md +211 -0
  156. package/docs/storage.md +450 -0
  157. package/docs/templates.md +315 -0
  158. package/docs/testing.md +125 -0
  159. package/docs/transformers.md +381 -0
  160. package/docs/url-builder.md +295 -0
  161. package/docs/validation.md +288 -0
  162. package/docs/views.md +267 -0
  163. package/docs/vite.md +434 -0
  164. package/llms-full.txt +17694 -0
  165. package/llms.txt +116 -0
  166. package/package.json +38 -7
@@ -42,6 +42,9 @@ function appKey() {
42
42
  }
43
43
  /* ------------------------------- hashing ------------------------------- */
44
44
  const DEFAULT_ITERATIONS = 100_000;
45
+ /** When true, `hash` skips PBKDF2 for a trivial (insecure) scheme — tests only. */
46
+ let faking = false;
47
+ const FAKE_PREFIX = "fake$";
45
48
  async function pbkdf2(password, salt, iterations) {
46
49
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
47
50
  const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: salt, iterations, hash: "SHA-256" }, key, 256);
@@ -50,12 +53,16 @@ async function pbkdf2(password, salt, iterations) {
50
53
  export const hash = {
51
54
  /** Hash a password (PBKDF2-SHA256 with a random salt). */
52
55
  async make(password, iterations = DEFAULT_ITERATIONS) {
56
+ if (faking)
57
+ return `${FAKE_PREFIX}${password}`;
53
58
  const salt = crypto.getRandomValues(new Uint8Array(16));
54
59
  const derived = await pbkdf2(password, salt, iterations);
55
60
  return `pbkdf2_sha256$${iterations}$${b64(salt)}$${b64(derived)}`;
56
61
  },
57
62
  /** Verify a password against a stored hash. Returns false for any malformed hash. */
58
63
  async verify(hashed, password) {
64
+ if (faking)
65
+ return hashed === `${FAKE_PREFIX}${password}`;
59
66
  const [algo, iter, salt64, hash64] = hashed.split("$");
60
67
  if (algo !== "pbkdf2_sha256" || !iter || !salt64 || !hash64)
61
68
  return false;
@@ -73,9 +80,35 @@ export const hash = {
73
80
  },
74
81
  /** Whether a hash was made with fewer iterations than the current default. */
75
82
  needsRehash(hashed, iterations = DEFAULT_ITERATIONS) {
83
+ if (faking)
84
+ return false;
76
85
  const iter = Number(hashed.split("$")[1]);
77
86
  return !iter || iter < iterations;
78
87
  },
88
+ /**
89
+ * Swap real hashing for a trivial, **insecure** scheme so tests that create
90
+ * many users don't pay for PBKDF2. `make` returns `fake$<password>` and
91
+ * `verify` just compares — near-instant. Call in a test setup hook; undo with
92
+ * `restore()`. Never use outside tests.
93
+ */
94
+ fake() {
95
+ faking = true;
96
+ },
97
+ /** Restore real PBKDF2 hashing after `fake()`. */
98
+ restore() {
99
+ faking = false;
100
+ },
101
+ /**
102
+ * A valid dummy hash (of a random secret) at the default cost. Compare against
103
+ * it when a user *isn't* found so login spends the same time as a wrong
104
+ * password — otherwise a fast "no such user" response leaks which emails are
105
+ * registered (a timing/enumeration attack).
106
+ *
107
+ * const user = await findUserByEmail(email);
108
+ * const ok = await hash.verify(user?.password ?? hash.dummy, password);
109
+ * if (ok && user) auth().login(user.id); // `user &&` so the dummy never authenticates
110
+ */
111
+ dummy: "pbkdf2_sha256$100000$7uVVFNW3RCry5kPKJQUgTw==$fOfxeFDnxv5A3rhl6bcWGKJQhmcK8x6XNfe9Z88WO/A=",
79
112
  };
80
113
  /* ----------------------------- encryption ------------------------------ */
81
114
  async function aesKey() {
@@ -83,24 +116,51 @@ async function aesKey() {
83
116
  return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
84
117
  }
85
118
  export const encryption = {
86
- /** Encrypt any JSON-serializable value (AES-GCM), keyed by config('app.key'). */
87
- async encrypt(value) {
119
+ /**
120
+ * Encrypt any JSON-serializable value (AES-GCM), keyed by `config('app.key')`.
121
+ * `expiresIn` makes the token self-expire; `purpose` binds it to a context
122
+ * (e.g. `"password-reset"`) so a token minted for one use can't be replayed for
123
+ * another.
124
+ */
125
+ async encrypt(value, options = {}) {
126
+ // Wrap in a small envelope so expiry/purpose travel inside the ciphertext.
127
+ const envelope = { __k: 1, v: value };
128
+ if (options.expiresIn != null)
129
+ envelope.exp = Date.now() + seconds(options.expiresIn) * 1000;
130
+ if (options.purpose != null)
131
+ envelope.p = options.purpose;
88
132
  const iv = crypto.getRandomValues(new Uint8Array(12));
89
- const data = new TextEncoder().encode(JSON.stringify(value));
133
+ const data = new TextEncoder().encode(JSON.stringify(envelope));
90
134
  const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, await aesKey(), data));
91
135
  const packed = new Uint8Array(iv.length + cipher.length);
92
136
  packed.set(iv);
93
137
  packed.set(cipher, iv.length);
94
138
  return b64(packed);
95
139
  },
96
- /** Decrypt a value; returns null if the payload is tampered or invalid. */
97
- async decrypt(payload) {
140
+ /**
141
+ * Decrypt a value; returns `null` if the payload is tampered, invalid, expired,
142
+ * or minted for a different `purpose`. Never throws.
143
+ */
144
+ async decrypt(payload, options = {}) {
98
145
  try {
99
146
  const bytes = fromB64(payload);
100
147
  const iv = bytes.slice(0, 12);
101
148
  const cipher = bytes.slice(12);
102
149
  const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, await aesKey(), cipher);
103
- return JSON.parse(new TextDecoder().decode(plain));
150
+ const parsed = JSON.parse(new TextDecoder().decode(plain));
151
+ // Envelope (new format): enforce expiry + purpose.
152
+ if (parsed && typeof parsed === "object" && parsed.__k === 1) {
153
+ const env = parsed;
154
+ if (typeof env.exp === "number" && Date.now() >= env.exp)
155
+ return null;
156
+ if ((options.purpose ?? null) !== (env.p ?? null))
157
+ return null;
158
+ return env.v;
159
+ }
160
+ // Legacy plain value (encrypted before envelopes). A required purpose can't match.
161
+ if (options.purpose != null)
162
+ return null;
163
+ return parsed;
104
164
  }
105
165
  catch {
106
166
  return null;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * CSRF protection for server-rendered apps. A synchronizer token lives in the
3
+ * session; state-changing requests (`POST`/`PUT`/`PATCH`/`DELETE`) must echo it
4
+ * back, or they're rejected with `419 Page Expired`. Needs `sessionMiddleware()`.
5
+ *
6
+ * this.use(sessionMiddleware());
7
+ * this.use(csrf());
8
+ *
9
+ * Put the token in forms with `csrfField()`; SPAs get it for free — `csrf()` also
10
+ * writes an `XSRF-TOKEN` cookie that axios/fetch libraries send back as the
11
+ * `X-XSRF-TOKEN` header automatically. The token is also read from a `_token` or
12
+ * `_csrf` field, or the `X-CSRF-Token` header.
13
+ */
14
+ import type { MiddlewareHandler } from "hono";
15
+ /** The current request's CSRF token, minting and storing one if absent. */
16
+ export declare function csrfToken(): string;
17
+ /** A hidden form input carrying the CSRF token — drop it into any `<form method="POST">`. */
18
+ export declare function csrfField(): string;
19
+ export interface CsrfOptions {
20
+ /** Paths exempt from verification (webhooks, callbacks). Trailing `*` matches a prefix. */
21
+ except?: (string | RegExp)[];
22
+ /** Write the readable `XSRF-TOKEN` cookie for SPA libraries. Default true. */
23
+ cookie?: boolean;
24
+ }
25
+ export declare function csrf(options?: CsrfOptions): MiddlewareHandler;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * CSRF protection for server-rendered apps. A synchronizer token lives in the
3
+ * session; state-changing requests (`POST`/`PUT`/`PATCH`/`DELETE`) must echo it
4
+ * back, or they're rejected with `419 Page Expired`. Needs `sessionMiddleware()`.
5
+ *
6
+ * this.use(sessionMiddleware());
7
+ * this.use(csrf());
8
+ *
9
+ * Put the token in forms with `csrfField()`; SPAs get it for free — `csrf()` also
10
+ * writes an `XSRF-TOKEN` cookie that axios/fetch libraries send back as the
11
+ * `X-XSRF-TOKEN` header automatically. The token is also read from a `_token` or
12
+ * `_csrf` field, or the `X-CSRF-Token` header.
13
+ */
14
+ import { setCookie } from "hono/cookie";
15
+ import { HttpException } from "./exceptions.js";
16
+ import { session } from "./session.js";
17
+ import { request } from "./request.js";
18
+ const KEY = "_csrf";
19
+ const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]);
20
+ function randomToken() {
21
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
22
+ let s = "";
23
+ for (const b of bytes)
24
+ s += String.fromCharCode(b);
25
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
26
+ }
27
+ function safeEqual(a, b) {
28
+ if (a.length !== b.length)
29
+ return false;
30
+ let result = 0;
31
+ for (let i = 0; i < a.length; i++)
32
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
33
+ return result === 0;
34
+ }
35
+ /** The current request's CSRF token, minting and storing one if absent. */
36
+ export function csrfToken() {
37
+ const sess = session();
38
+ let token = sess.get(KEY, undefined);
39
+ if (!token) {
40
+ token = randomToken();
41
+ sess.put(KEY, token);
42
+ }
43
+ return token;
44
+ }
45
+ /** A hidden form input carrying the CSRF token — drop it into any `<form method="POST">`. */
46
+ export function csrfField() {
47
+ return `<input type="hidden" name="_token" value="${csrfToken()}">`;
48
+ }
49
+ async function submitted(c) {
50
+ const header = c.req.header("x-csrf-token") ?? c.req.header("x-xsrf-token");
51
+ if (header)
52
+ return header;
53
+ const all = await request.all(); // shares keel's body cache — safe to read
54
+ return (all._token ?? all._csrf);
55
+ }
56
+ function isExcepted(path, except) {
57
+ return except.some((rule) => typeof rule === "string"
58
+ ? rule.endsWith("*")
59
+ ? path.startsWith(rule.slice(0, -1))
60
+ : path === rule
61
+ : rule.test(path));
62
+ }
63
+ export function csrf(options = {}) {
64
+ const except = options.except ?? [];
65
+ return async (c, next) => {
66
+ const token = csrfToken(); // ensures a token exists in the session
67
+ if (options.cookie !== false) {
68
+ setCookie(c, "XSRF-TOKEN", token, { path: "/", sameSite: "Lax" }); // readable by JS on purpose
69
+ }
70
+ if (UNSAFE.has(c.req.method.toUpperCase()) && !isExcepted(c.req.path, except)) {
71
+ const provided = await submitted(c);
72
+ if (!provided || !safeEqual(provided, token)) {
73
+ throw new HttpException(419, "CSRF token mismatch");
74
+ }
75
+ }
76
+ await next();
77
+ };
78
+ }
@@ -31,16 +31,40 @@ export interface Connection {
31
31
  }
32
32
  export type Dialect = "sqlite" | "mysql" | "postgres";
33
33
  export type Operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "like";
34
- /** Register the database connection (and dialect) used by `db()`. */
34
+ /** A registered connection — the driver bridge plus its SQL dialect. */
35
+ interface Source {
36
+ conn: Connection;
37
+ dialect: Dialect;
38
+ }
39
+ /** Register the default connection (and dialect) used by `db()`. */
35
40
  export declare function setConnection(conn: Connection, driverDialect?: Dialect): void;
41
+ /**
42
+ * Register a *named* connection alongside any others — the way to use more than
43
+ * one database. Point a query or model at it by name; nothing else changes.
44
+ *
45
+ * addConnection("reporting", pgConn, "postgres");
46
+ * await db("events", "reporting").where("kind", "signup").count();
47
+ */
48
+ export declare function addConnection(name: string, conn: Connection, driverDialect?: Dialect): void;
49
+ /** Choose which registered connection `db()` and models use when none is named. */
50
+ export declare function setDefaultConnection(name: string): void;
51
+ /** The names of every registered connection. */
52
+ export declare function connectionNames(): string[];
53
+ /** Unregister every connection — a test helper for a clean slate. */
54
+ export declare function clearConnections(): void;
36
55
  export declare class QueryBuilder<T extends Row = Row> {
37
56
  private table;
57
+ private getSource;
38
58
  private wheres;
39
59
  private orders;
40
60
  private columns;
41
61
  private _limit?;
42
62
  private _offset?;
43
- constructor(table: string);
63
+ constructor(table: string, getSource: () => Source);
64
+ /** Run a row-returning query on this builder's connection, dialect-adjusted. */
65
+ private runSelect;
66
+ /** Run a write on this builder's connection, dialect-adjusted. */
67
+ private runWrite;
44
68
  select(...columns: string[]): this;
45
69
  where(column: string, value: unknown): this;
46
70
  where(column: string, operator: Operator, value: unknown): this;
@@ -79,5 +103,26 @@ export declare class QueryBuilder<T extends Row = Row> {
79
103
  update(data: Row): Promise<WriteResult>;
80
104
  delete(): Promise<WriteResult>;
81
105
  }
82
- /** Start a query against a table. */
83
- export declare function db<T extends Row = Row>(table: string): QueryBuilder<T>;
106
+ /** Start a query against a table, on the default connection or a named one. */
107
+ export declare function db<T extends Row = Row>(table: string, connectionName?: string): QueryBuilder<T>;
108
+ /** A handle to one registered connection — query it, or run raw SQL on it. */
109
+ export interface ConnectionHandle {
110
+ /** Start a query against a table on this connection. */
111
+ table<T extends Row = Row>(table: string): QueryBuilder<T>;
112
+ /** Run a raw row-returning query (`?` placeholders, dialect-adjusted). */
113
+ select(sql: string, bindings?: unknown[]): Promise<Row[]>;
114
+ /** Run a raw write (`?` placeholders, dialect-adjusted). */
115
+ write(sql: string, bindings?: unknown[]): Promise<WriteResult>;
116
+ /** This connection's SQL dialect. */
117
+ readonly dialect: Dialect;
118
+ }
119
+ /**
120
+ * Get a handle to a named connection (or the default). Use it to run several
121
+ * queries against one database without repeating the name, or to reach the raw
122
+ * `select`/`write` bridge.
123
+ *
124
+ * const reporting = connection("reporting");
125
+ * await reporting.table("events").where("kind", "signup").count();
126
+ */
127
+ export declare function connection(name?: string): ConnectionHandle;
128
+ export {};
@@ -9,20 +9,56 @@
9
9
  * const user = await db("users").where("id", 1).first();
10
10
  * await db("users").insert({ email });
11
11
  */
12
- let connection;
13
- let dialect = "sqlite";
14
- /** Register the database connection (and dialect) used by `db()`. */
12
+ /**
13
+ * The connection registry. An app can talk to several databases at once —
14
+ * register each by name, then route a query with `db(table, name)`, a whole
15
+ * model with `static connection`, or a handle from `connection(name)`. The
16
+ * unnamed default lives under `"default"` so `db(table)` and `setConnection()`
17
+ * keep working unchanged.
18
+ */
19
+ const registry = new Map();
20
+ let defaultConnection = "default";
21
+ /** Register the default connection (and dialect) used by `db()`. */
15
22
  export function setConnection(conn, driverDialect = "sqlite") {
16
- connection = conn;
17
- dialect = driverDialect;
23
+ registry.set("default", { conn, dialect: driverDialect });
24
+ defaultConnection = "default";
25
+ }
26
+ /**
27
+ * Register a *named* connection alongside any others — the way to use more than
28
+ * one database. Point a query or model at it by name; nothing else changes.
29
+ *
30
+ * addConnection("reporting", pgConn, "postgres");
31
+ * await db("events", "reporting").where("kind", "signup").count();
32
+ */
33
+ export function addConnection(name, conn, driverDialect = "sqlite") {
34
+ registry.set(name, { conn, dialect: driverDialect });
35
+ }
36
+ /** Choose which registered connection `db()` and models use when none is named. */
37
+ export function setDefaultConnection(name) {
38
+ if (!registry.has(name))
39
+ throw new Error(`No database connection "${name}" to make default.`);
40
+ defaultConnection = name;
41
+ }
42
+ /** The names of every registered connection. */
43
+ export function connectionNames() {
44
+ return [...registry.keys()];
18
45
  }
19
- function conn() {
20
- if (!connection)
21
- throw new Error("No database connection. Call setConnection(conn, dialect).");
22
- return connection;
46
+ /** Unregister every connection — a test helper for a clean slate. */
47
+ export function clearConnections() {
48
+ registry.clear();
49
+ defaultConnection = "default";
23
50
  }
24
- /** Render `?` placeholders for the active dialect (Postgres uses $1, $2, …). */
25
- function placeholders(sql) {
51
+ /** Resolve a connection by name (or the default); throws if it isn't registered. */
52
+ function resolve(name) {
53
+ const source = registry.get(name ?? defaultConnection);
54
+ if (!source) {
55
+ throw new Error(`No database connection${name ? ` "${name}"` : ""}. ` +
56
+ `Call setConnection(conn, dialect) or addConnection(name, conn, dialect).`);
57
+ }
58
+ return source;
59
+ }
60
+ /** Render `?` placeholders for a dialect (Postgres uses $1, $2, …). */
61
+ function placeholders(sql, dialect) {
26
62
  if (dialect !== "postgres")
27
63
  return sql;
28
64
  let i = 0;
@@ -30,13 +66,28 @@ function placeholders(sql) {
30
66
  }
31
67
  export class QueryBuilder {
32
68
  table;
69
+ getSource;
33
70
  wheres = [];
34
71
  orders = [];
35
72
  columns = "*";
36
73
  _limit;
37
74
  _offset;
38
- constructor(table) {
75
+ // The connection is resolved lazily, when a query actually runs — so building
76
+ // a query never throws, and an unregistered connection surfaces as a rejected
77
+ // read/write rather than a synchronous error at construction.
78
+ constructor(table, getSource) {
39
79
  this.table = table;
80
+ this.getSource = getSource;
81
+ }
82
+ /** Run a row-returning query on this builder's connection, dialect-adjusted. */
83
+ runSelect(sql, bindings) {
84
+ const source = this.getSource();
85
+ return source.conn.select(placeholders(sql, source.dialect), bindings);
86
+ }
87
+ /** Run a write on this builder's connection, dialect-adjusted. */
88
+ runWrite(sql, bindings) {
89
+ const source = this.getSource();
90
+ return source.conn.write(placeholders(sql, source.dialect), bindings);
40
91
  }
41
92
  select(...columns) {
42
93
  this.columns = columns.length ? columns.join(", ") : "*";
@@ -114,7 +165,7 @@ export class QueryBuilder {
114
165
  sql += ` LIMIT ${this._limit}`;
115
166
  if (this._offset != null)
116
167
  sql += ` OFFSET ${this._offset}`;
117
- return (await conn().select(placeholders(sql), where.bindings));
168
+ return (await this.runSelect(sql, where.bindings));
118
169
  }
119
170
  async first() {
120
171
  this._limit = 1;
@@ -123,7 +174,7 @@ export class QueryBuilder {
123
174
  }
124
175
  async count() {
125
176
  const where = this.whereClause();
126
- const rows = (await conn().select(placeholders(`SELECT COUNT(*) AS count FROM ${this.table}${where.sql}`), where.bindings));
177
+ const rows = (await this.runSelect(`SELECT COUNT(*) AS count FROM ${this.table}${where.sql}`, where.bindings));
127
178
  return Number(rows[0]?.count ?? 0);
128
179
  }
129
180
  async exists() {
@@ -131,7 +182,7 @@ export class QueryBuilder {
131
182
  }
132
183
  async aggregate(fn, column) {
133
184
  const where = this.whereClause();
134
- const rows = (await conn().select(placeholders(`SELECT ${fn}(${column}) AS agg FROM ${this.table}${where.sql}`), where.bindings));
185
+ const rows = (await this.runSelect(`SELECT ${fn}(${column}) AS agg FROM ${this.table}${where.sql}`, where.bindings));
135
186
  return Number(rows[0]?.agg ?? 0);
136
187
  }
137
188
  sum(column) {
@@ -178,7 +229,7 @@ export class QueryBuilder {
178
229
  const sql = `INSERT INTO ${this.table} (${keys.join(", ")}) VALUES (${keys
179
230
  .map(() => "?")
180
231
  .join(", ")})`;
181
- return conn().write(placeholders(sql), Object.values(data));
232
+ return this.runWrite(sql, Object.values(data));
182
233
  }
183
234
  async insertGetId(data) {
184
235
  return (await this.insert(data)).insertId;
@@ -187,17 +238,34 @@ export class QueryBuilder {
187
238
  const keys = Object.keys(data);
188
239
  const set = keys.map((k) => `${k} = ?`).join(", ");
189
240
  const where = this.whereClause();
190
- return conn().write(placeholders(`UPDATE ${this.table} SET ${set}${where.sql}`), [
241
+ return this.runWrite(`UPDATE ${this.table} SET ${set}${where.sql}`, [
191
242
  ...Object.values(data),
192
243
  ...where.bindings,
193
244
  ]);
194
245
  }
195
246
  async delete() {
196
247
  const where = this.whereClause();
197
- return conn().write(placeholders(`DELETE FROM ${this.table}${where.sql}`), where.bindings);
248
+ return this.runWrite(`DELETE FROM ${this.table}${where.sql}`, where.bindings);
198
249
  }
199
250
  }
200
- /** Start a query against a table. */
201
- export function db(table) {
202
- return new QueryBuilder(table);
251
+ /** Start a query against a table, on the default connection or a named one. */
252
+ export function db(table, connectionName) {
253
+ return new QueryBuilder(table, () => resolve(connectionName));
254
+ }
255
+ /**
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.
259
+ *
260
+ * const reporting = connection("reporting");
261
+ * await reporting.table("events").where("kind", "signup").count();
262
+ */
263
+ export function connection(name) {
264
+ const source = resolve(name);
265
+ return {
266
+ 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),
269
+ dialect: source.dialect,
270
+ };
203
271
  }
@@ -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
  }