@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.
- package/AGENTS.md +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +14 -2
- package/dist/core/auth.d.ts +47 -0
- package/dist/core/auth.js +77 -0
- package/dist/core/authorization.d.ts +9 -1
- package/dist/core/authorization.js +22 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/container.d.ts +20 -0
- package/dist/core/container.js +52 -0
- package/dist/core/cors.d.ts +29 -0
- package/dist/core/cors.js +72 -0
- package/dist/core/crypto.d.ts +40 -4
- package/dist/core/crypto.js +66 -6
- package/dist/core/csrf.d.ts +25 -0
- package/dist/core/csrf.js +78 -0
- package/dist/core/database.d.ts +49 -4
- package/dist/core/database.js +89 -21
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +15 -3
- package/dist/core/helpers.js +23 -3
- package/dist/core/index.d.ts +33 -18
- package/dist/core/index.js +16 -8
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/model.d.ts +2 -0
- package/dist/core/model.js +16 -14
- package/dist/core/provider.d.ts +7 -0
- package/dist/core/provider.js +7 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/rate-limit.js +3 -0
- package/dist/core/relations.js +13 -13
- package/dist/core/request.d.ts +26 -0
- package/dist/core/request.js +77 -0
- package/dist/core/shield.d.ts +39 -0
- package/dist/core/shield.js +60 -0
- package/dist/core/social.d.ts +173 -0
- package/dist/core/social.js +337 -0
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/core/tokens.d.ts +74 -0
- package/dist/core/tokens.js +155 -0
- package/dist/db/d1.d.ts +32 -0
- package/dist/db/d1.js +26 -0
- package/dist/db/libsql.d.ts +29 -0
- package/dist/db/libsql.js +32 -0
- package/dist/db/pg.d.ts +29 -0
- package/dist/db/pg.js +33 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- package/package.json +38 -7
package/dist/core/crypto.js
CHANGED
|
@@ -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
|
-
/**
|
|
87
|
-
|
|
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(
|
|
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
|
-
/**
|
|
97
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/core/database.d.ts
CHANGED
|
@@ -31,16 +31,40 @@ export interface Connection {
|
|
|
31
31
|
}
|
|
32
32
|
export type Dialect = "sqlite" | "mysql" | "postgres";
|
|
33
33
|
export type Operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "like";
|
|
34
|
-
/**
|
|
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 {};
|
package/dist/core/database.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
46
|
+
/** Unregister every connection — a test helper for a clean slate. */
|
|
47
|
+
export function clearConnections() {
|
|
48
|
+
registry.clear();
|
|
49
|
+
defaultConnection = "default";
|
|
23
50
|
}
|
|
24
|
-
/**
|
|
25
|
-
function
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/core/events.d.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
16
|
-
off<T = unknown>(event:
|
|
17
|
-
/**
|
|
18
|
-
|
|
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
|
}
|