@shaferllc/keel 0.68.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/README.md +15 -5
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +93 -0
- package/dist/api/resource.js +185 -0
- package/dist/core/application.js +6 -0
- package/dist/core/cache.d.ts +1 -2
- package/dist/core/cache.js +9 -2
- package/dist/core/cli/index.d.ts +28 -0
- package/dist/core/cli/index.js +427 -0
- package/dist/core/cli/stubs.d.ts +14 -0
- package/dist/core/cli/stubs.js +105 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/health.d.ts +2 -2
- package/dist/core/health.js +2 -2
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +25 -4
- package/dist/core/index.js +13 -3
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/logger.d.ts +7 -0
- package/dist/core/logger.js +28 -1
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +117 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.js +26 -5
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.js +15 -3
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +171 -1
- package/dist/core/testing.js +505 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +22 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +737 -3
- package/docs/api-resources.md +119 -0
- package/docs/configuration.md +74 -0
- package/docs/console.md +217 -13
- package/docs/database.md +101 -0
- package/docs/examples/api-resources.ts +86 -0
- package/docs/examples/configuration.ts +40 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/database.ts +90 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/logger.ts +74 -0
- package/docs/examples/mail.ts +103 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/testing.ts +150 -0
- package/docs/health.md +4 -4
- package/docs/i18n.md +302 -0
- package/docs/logger.md +156 -10
- package/docs/mail.md +73 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/storage.md +5 -3
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +3 -4
- package/docs/testing.md +252 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +2389 -241
- package/llms.txt +13 -1
- package/package.json +32 -6
package/dist/core/database.js
CHANGED
|
@@ -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
|
-
/**
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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
|
-
*
|
|
261
|
-
*
|
|
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
|
|
268
|
-
write: (sql, bindings = []) => source
|
|
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 {};
|
package/dist/core/env.js
ADDED
|
@@ -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
|
+
}
|
package/dist/core/health.d.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* this.use(healthCheck()); // serves both endpoints
|
|
15
15
|
*
|
|
16
16
|
* Keel ships the checks that mean something wherever it runs — a database, Redis,
|
|
17
|
-
* the cache. Deliberately absent are
|
|
18
|
-
*
|
|
17
|
+
* the cache. Deliberately absent are disk-space and heap/RSS checks: they measure
|
|
18
|
+
* a Node process, and on Workers there isn't one.
|
|
19
19
|
*/
|
|
20
20
|
import type { MiddlewareHandler } from "hono";
|
|
21
21
|
export type HealthStatus = "ok" | "warning" | "error";
|
package/dist/core/health.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* this.use(healthCheck()); // serves both endpoints
|
|
15
15
|
*
|
|
16
16
|
* Keel ships the checks that mean something wherever it runs — a database, Redis,
|
|
17
|
-
* the cache. Deliberately absent are
|
|
18
|
-
*
|
|
17
|
+
* the cache. Deliberately absent are disk-space and heap/RSS checks: they measure
|
|
18
|
+
* a Node process, and on Workers there isn't one.
|
|
19
19
|
*/
|
|
20
20
|
import { connection } from "./database.js";
|
|
21
21
|
import { redis } from "./redis.js";
|
|
@@ -21,6 +21,8 @@ export declare class HttpKernel {
|
|
|
21
21
|
build(): Hono;
|
|
22
22
|
/** Compile a set of routes onto a fresh Hono instance. */
|
|
23
23
|
private compile;
|
|
24
|
+
/** Emit `request.handled` with method, path, status, timing, and headers. */
|
|
25
|
+
private reportRequest;
|
|
24
26
|
private handle;
|
|
25
27
|
/** Default rendering: HTML for browsers, JSON otherwise; details in debug. */
|
|
26
28
|
protected renderException(err: unknown, c: Context): Response;
|
package/dist/core/http/kernel.js
CHANGED
|
@@ -8,6 +8,7 @@ import { contextStorage } from "hono/context-storage";
|
|
|
8
8
|
import { Config } from "../config.js";
|
|
9
9
|
import { HttpException, NotFoundException, ValidationException, STATUS_TEXT, } from "../exceptions.js";
|
|
10
10
|
import { Router } from "./router.js";
|
|
11
|
+
import { instrument, runRequest, newRequestId, currentRequestId } from "../instrumentation.js";
|
|
11
12
|
/** Per-request stash of subdomain params, keyed by the raw Request. */
|
|
12
13
|
const SUBDOMAINS = new WeakMap();
|
|
13
14
|
/** Compile a host pattern like ":tenant.example.com" into a matcher. */
|
|
@@ -96,6 +97,26 @@ export class HttpKernel {
|
|
|
96
97
|
c.set("subdomains", subs);
|
|
97
98
|
await next();
|
|
98
99
|
});
|
|
100
|
+
// Open a request scope: a request id everything downstream (queries, logs,
|
|
101
|
+
// jobs) can attribute itself to, plus timing. Emitting `request.handled` and
|
|
102
|
+
// `exception` from here — inside the scope — is what lets a watcher tie a
|
|
103
|
+
// request to everything that happened within it. A no-op stream when nobody
|
|
104
|
+
// is listening, so this stays cheap for apps that don't instrument.
|
|
105
|
+
hono.use("*", async (c, next) => {
|
|
106
|
+
const id = newRequestId();
|
|
107
|
+
const start = Date.now();
|
|
108
|
+
try {
|
|
109
|
+
await runRequest(id, () => next());
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Errors thrown *outside* Hono's per-request handling (rare) land here;
|
|
113
|
+
// the common case (a handler throwing) is caught by Hono and reaches
|
|
114
|
+
// `handle()` instead, which is where `exception` is emitted.
|
|
115
|
+
this.reportRequest(c, id, start, err instanceof HttpException ? err.status : 500);
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
this.reportRequest(c, id, start, c.res.status);
|
|
119
|
+
});
|
|
99
120
|
for (const mw of this.middleware) {
|
|
100
121
|
hono.use("*", mw);
|
|
101
122
|
}
|
|
@@ -128,7 +149,34 @@ export class HttpKernel {
|
|
|
128
149
|
hono.onError((err, c) => this.handle(err, c));
|
|
129
150
|
return hono;
|
|
130
151
|
}
|
|
152
|
+
/** Emit `request.handled` with method, path, status, timing, and headers. */
|
|
153
|
+
reportRequest(c, id, start, status) {
|
|
154
|
+
const url = new URL(c.req.url);
|
|
155
|
+
const headers = {};
|
|
156
|
+
c.req.raw.headers.forEach((v, k) => (headers[k] = v));
|
|
157
|
+
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
|
|
158
|
+
instrument("request.handled", {
|
|
159
|
+
id,
|
|
160
|
+
method: c.req.method,
|
|
161
|
+
path: url.pathname,
|
|
162
|
+
status,
|
|
163
|
+
durationMs: Date.now() - start,
|
|
164
|
+
headers,
|
|
165
|
+
...(ip ? { ip } : {}),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
131
168
|
async handle(err, c) {
|
|
169
|
+
// The single choke point every error (thrown handler or unmatched route)
|
|
170
|
+
// passes through — and it runs inside the request scope, so `currentRequestId`
|
|
171
|
+
// ties the exception to its request.
|
|
172
|
+
const requestId = currentRequestId();
|
|
173
|
+
instrument("exception", {
|
|
174
|
+
error: err,
|
|
175
|
+
method: c.req.method,
|
|
176
|
+
path: new URL(c.req.url).pathname,
|
|
177
|
+
status: err instanceof HttpException ? err.status : 500,
|
|
178
|
+
...(requestId ? { requestId } : {}),
|
|
179
|
+
});
|
|
132
180
|
// Reportable exceptions: give them a chance to log/report themselves.
|
|
133
181
|
const maybe = err;
|
|
134
182
|
if (typeof maybe?.report === "function") {
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Router facade. Collects route definitions declaratively; the HTTP kernel
|
|
3
3
|
* compiles them onto the underlying Hono instance at boot.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* A fluent API: named routes, per-route and group middleware, prefixes, param
|
|
6
|
+
* constraints, resource routes, and URL generation.
|
|
7
7
|
*/
|
|
8
8
|
import type { Context as HonoContext, MiddlewareHandler } from "hono";
|
|
9
9
|
import type { Container, Constructor } from "../container.js";
|
|
@@ -66,7 +66,7 @@ export declare class Route {
|
|
|
66
66
|
as(name: string): this;
|
|
67
67
|
/** Attach middleware that runs only for this route (after group middleware). */
|
|
68
68
|
middleware(mw: MiddlewareRef | MiddlewareRef[]): this;
|
|
69
|
-
/** Alias for middleware()
|
|
69
|
+
/** Alias for middleware(). */
|
|
70
70
|
use(mw: MiddlewareRef | MiddlewareRef[]): this;
|
|
71
71
|
/** Constrain a route parameter with a regex, source string, or matcher. */
|
|
72
72
|
where(param: string, matcher: Matcher): this;
|
|
@@ -81,7 +81,7 @@ export declare class RouteGroup {
|
|
|
81
81
|
constructor(routes: RouteDefinition[]);
|
|
82
82
|
prefix(prefix: string): this;
|
|
83
83
|
middleware(mw: MiddlewareRef | MiddlewareRef[]): this;
|
|
84
|
-
/** Alias for middleware()
|
|
84
|
+
/** Alias for middleware(). */
|
|
85
85
|
use(mw: MiddlewareRef | MiddlewareRef[]): this;
|
|
86
86
|
/** Attach metadata to every route in the group (a route's own config wins). */
|
|
87
87
|
config(data: Record<string, unknown>): this;
|
|
@@ -115,7 +115,7 @@ declare class RouteMatcher {
|
|
|
115
115
|
constructor(router: Router, path: string);
|
|
116
116
|
/** Redirect to a path or URL. */
|
|
117
117
|
redirect(to: string, status?: number): Route;
|
|
118
|
-
/** Alias for redirect()
|
|
118
|
+
/** Alias for redirect(). */
|
|
119
119
|
redirectToPath(to: string, status?: number): Route;
|
|
120
120
|
/** Redirect to a named route, optionally with params and a query string. */
|
|
121
121
|
redirectToRoute(name: string, params?: Record<string, string | number>, options?: {
|
package/dist/core/http/router.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Router facade. Collects route definitions declaratively; the HTTP kernel
|
|
3
3
|
* compiles them onto the underlying Hono instance at boot.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* A fluent API: named routes, per-route and group middleware, prefixes, param
|
|
6
|
+
* constraints, resource routes, and URL generation.
|
|
7
7
|
*/
|
|
8
8
|
import { view, config } from "../helpers.js";
|
|
9
9
|
import { redirect as makeRedirect, request } from "../request.js";
|
|
@@ -56,7 +56,7 @@ export class Route {
|
|
|
56
56
|
this.def.middleware.push(...(Array.isArray(mw) ? mw : [mw]));
|
|
57
57
|
return this;
|
|
58
58
|
}
|
|
59
|
-
/** Alias for middleware()
|
|
59
|
+
/** Alias for middleware(). */
|
|
60
60
|
use(mw) {
|
|
61
61
|
return this.middleware(mw);
|
|
62
62
|
}
|
|
@@ -94,7 +94,7 @@ export class RouteGroup {
|
|
|
94
94
|
r.middleware.unshift(...list); // group runs first
|
|
95
95
|
return this;
|
|
96
96
|
}
|
|
97
|
-
/** Alias for middleware()
|
|
97
|
+
/** Alias for middleware(). */
|
|
98
98
|
use(mw) {
|
|
99
99
|
return this.middleware(mw);
|
|
100
100
|
}
|
|
@@ -200,7 +200,7 @@ class RouteMatcher {
|
|
|
200
200
|
redirect(to, status = 302) {
|
|
201
201
|
return this.router.get(this.path, () => makeRedirect(to, status));
|
|
202
202
|
}
|
|
203
|
-
/** Alias for redirect()
|
|
203
|
+
/** Alias for redirect(). */
|
|
204
204
|
redirectToPath(to, status = 302) {
|
|
205
205
|
return this.redirect(to, status);
|
|
206
206
|
}
|