@shaferllc/keel 0.68.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +15 -5
  3. package/dist/api/api.config.stub +9 -0
  4. package/dist/api/config.d.ts +13 -0
  5. package/dist/api/config.js +14 -0
  6. package/dist/api/index.d.ts +16 -0
  7. package/dist/api/index.js +13 -0
  8. package/dist/api/provider.d.ts +10 -0
  9. package/dist/api/provider.js +17 -0
  10. package/dist/api/query.d.ts +35 -0
  11. package/dist/api/query.js +42 -0
  12. package/dist/api/resource.d.ts +93 -0
  13. package/dist/api/resource.js +185 -0
  14. package/dist/core/application.js +6 -0
  15. package/dist/core/cache.d.ts +1 -2
  16. package/dist/core/cache.js +9 -2
  17. package/dist/core/cli/index.d.ts +28 -0
  18. package/dist/core/cli/index.js +427 -0
  19. package/dist/core/cli/stubs.d.ts +14 -0
  20. package/dist/core/cli/stubs.js +105 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/health.d.ts +2 -2
  32. package/dist/core/health.js +2 -2
  33. package/dist/core/http/kernel.d.ts +2 -0
  34. package/dist/core/http/kernel.js +48 -0
  35. package/dist/core/http/router.d.ts +5 -5
  36. package/dist/core/http/router.js +5 -5
  37. package/dist/core/i18n.d.ts +162 -0
  38. package/dist/core/i18n.js +472 -0
  39. package/dist/core/index.d.ts +25 -4
  40. package/dist/core/index.js +13 -3
  41. package/dist/core/instrumentation.d.ts +113 -0
  42. package/dist/core/instrumentation.js +52 -0
  43. package/dist/core/logger.d.ts +7 -0
  44. package/dist/core/logger.js +28 -1
  45. package/dist/core/notification.js +10 -1
  46. package/dist/core/package.d.ts +117 -0
  47. package/dist/core/package.js +169 -0
  48. package/dist/core/pages.d.ts +108 -0
  49. package/dist/core/pages.js +199 -0
  50. package/dist/core/queue.js +26 -5
  51. package/dist/core/repl.d.ts +33 -0
  52. package/dist/core/repl.js +88 -0
  53. package/dist/core/scheduler.js +6 -0
  54. package/dist/core/social.d.ts +4 -4
  55. package/dist/core/social.js +4 -4
  56. package/dist/core/storage.js +15 -3
  57. package/dist/core/telemetry.d.ts +208 -0
  58. package/dist/core/telemetry.js +383 -0
  59. package/dist/core/template.d.ts +2 -3
  60. package/dist/core/template.js +2 -3
  61. package/dist/core/testing.d.ts +171 -1
  62. package/dist/core/testing.js +505 -2
  63. package/dist/db/d1.js +13 -0
  64. package/dist/db/pg.d.ts +13 -0
  65. package/dist/db/pg.js +46 -4
  66. package/dist/openapi/config.d.ts +28 -0
  67. package/dist/openapi/config.js +25 -0
  68. package/dist/openapi/doc.d.ts +40 -0
  69. package/dist/openapi/doc.js +20 -0
  70. package/dist/openapi/export.d.ts +8 -0
  71. package/dist/openapi/export.js +19 -0
  72. package/dist/openapi/gate.d.ts +15 -0
  73. package/dist/openapi/gate.js +27 -0
  74. package/dist/openapi/index.d.ts +19 -0
  75. package/dist/openapi/index.js +15 -0
  76. package/dist/openapi/openapi.config.stub +29 -0
  77. package/dist/openapi/provider.d.ts +18 -0
  78. package/dist/openapi/provider.js +35 -0
  79. package/dist/openapi/routes.d.ts +9 -0
  80. package/dist/openapi/routes.js +23 -0
  81. package/dist/openapi/spec.d.ts +23 -0
  82. package/dist/openapi/spec.js +132 -0
  83. package/dist/openapi/ui.d.ts +8 -0
  84. package/dist/openapi/ui.js +31 -0
  85. package/dist/openapi/zod.d.ts +12 -0
  86. package/dist/openapi/zod.js +46 -0
  87. package/dist/watch/config.d.ts +33 -0
  88. package/dist/watch/config.js +38 -0
  89. package/dist/watch/entry.d.ts +53 -0
  90. package/dist/watch/entry.js +105 -0
  91. package/dist/watch/gate.d.ts +20 -0
  92. package/dist/watch/gate.js +32 -0
  93. package/dist/watch/index.d.ts +21 -0
  94. package/dist/watch/index.js +17 -0
  95. package/dist/watch/migration.d.ts +7 -0
  96. package/dist/watch/migration.js +20 -0
  97. package/dist/watch/provider.d.ts +22 -0
  98. package/dist/watch/provider.js +58 -0
  99. package/dist/watch/prune.d.ts +11 -0
  100. package/dist/watch/prune.js +22 -0
  101. package/dist/watch/recorder.d.ts +24 -0
  102. package/dist/watch/recorder.js +39 -0
  103. package/dist/watch/routes.d.ts +13 -0
  104. package/dist/watch/routes.js +55 -0
  105. package/dist/watch/store.d.ts +54 -0
  106. package/dist/watch/store.js +158 -0
  107. package/dist/watch/ui/dist/watch.css +1 -0
  108. package/dist/watch/ui/dist/watch.js +555 -0
  109. package/dist/watch/ui-shell.d.ts +12 -0
  110. package/dist/watch/ui-shell.js +24 -0
  111. package/dist/watch/watch.config.stub +47 -0
  112. package/dist/watch/watchers.d.ts +12 -0
  113. package/dist/watch/watchers.js +156 -0
  114. package/docs/ai-manifest.json +737 -3
  115. package/docs/api-resources.md +119 -0
  116. package/docs/configuration.md +74 -0
  117. package/docs/console.md +217 -13
  118. package/docs/database.md +101 -0
  119. package/docs/examples/api-resources.ts +86 -0
  120. package/docs/examples/configuration.ts +40 -0
  121. package/docs/examples/console.ts +134 -0
  122. package/docs/examples/database.ts +90 -0
  123. package/docs/examples/i18n.ts +117 -0
  124. package/docs/examples/logger.ts +74 -0
  125. package/docs/examples/mail.ts +103 -0
  126. package/docs/examples/pages.ts +82 -0
  127. package/docs/examples/telemetry.ts +127 -0
  128. package/docs/examples/testing.ts +150 -0
  129. package/docs/health.md +4 -4
  130. package/docs/i18n.md +302 -0
  131. package/docs/logger.md +156 -10
  132. package/docs/mail.md +73 -0
  133. package/docs/openapi.md +111 -0
  134. package/docs/packages.md +118 -0
  135. package/docs/pages.md +217 -0
  136. package/docs/storage.md +5 -3
  137. package/docs/telemetry.md +263 -0
  138. package/docs/templates.md +3 -4
  139. package/docs/testing.md +252 -0
  140. package/docs/watch.md +118 -0
  141. package/llms-full.txt +2389 -241
  142. package/llms.txt +13 -1
  143. package/package.json +32 -6
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A recorded entry — one thing that happened in the app: a request, a query, an
3
+ * exception, a log line. Every watcher produces these; the store persists them;
4
+ * the dashboard reads them back.
5
+ *
6
+ * Two ids give the UI its shape. `batchId` ties an entry to the request (or job,
7
+ * or scheduled run) it happened inside, so the dashboard can show a request and
8
+ * every query, log, and exception it produced. `familyHash` groups entries that
9
+ * are "the same" — the same SQL shape, the same exception — so recurring things
10
+ * can be counted and collapsed.
11
+ */
12
+ export type EntryType = "request" | "query" | "exception" | "log" | "mail" | "job" | "notification" | "cache" | "event" | "schedule";
13
+ /** Every entry type, in the order the dashboard shows its tabs. */
14
+ export declare const ENTRY_TYPES: EntryType[];
15
+ export interface Entry {
16
+ /** Unique id (32 hex). */
17
+ uuid: string;
18
+ /** The request/job/run this happened inside; groups related entries. */
19
+ batchId: string;
20
+ type: EntryType;
21
+ /** Groups "the same" entry — same SQL shape, same exception class+message. */
22
+ familyHash?: string;
23
+ /** The type-specific detail the dashboard renders. Always JSON-safe. */
24
+ content: Record<string, unknown>;
25
+ /** Free-form labels for filtering: `status:500`, `slow`, `connection:default`. */
26
+ tags: string[];
27
+ /** Epoch milliseconds. */
28
+ createdAt: number;
29
+ }
30
+ /** A filter over stored entries, for the list endpoint. */
31
+ export interface EntryFilter {
32
+ type?: EntryType;
33
+ tag?: string;
34
+ batchId?: string;
35
+ /** Keyset pagination: only entries created strictly before this timestamp. */
36
+ before?: number;
37
+ limit?: number;
38
+ }
39
+ /** A 32-hex id for an entry. */
40
+ export declare function newUuid(): string;
41
+ /** A short, stable hash (8 hex) of a string — for `familyHash`. */
42
+ export declare function familyHash(input: string): string;
43
+ /**
44
+ * Make an arbitrary value safe to store as JSON: unwrap Errors, truncate long
45
+ * strings, drop functions, and break cycles. Watchers hand user data (request
46
+ * bodies, job payloads, event payloads) straight through, so this is the barrier
47
+ * that keeps one weird value from poisoning the whole store.
48
+ */
49
+ export declare function jsonSafe(value: unknown, seen?: WeakSet<object>): unknown;
50
+ /** Redact auth/cookie headers before an entry is stored or shown. */
51
+ export declare function redactHeaders(headers: Record<string, string>): Record<string, string>;
52
+ /** Collapse a SQL statement to its shape, so `id = 1` and `id = 2` group together. */
53
+ export declare function sqlShape(sql: string): string;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * A recorded entry — one thing that happened in the app: a request, a query, an
3
+ * exception, a log line. Every watcher produces these; the store persists them;
4
+ * the dashboard reads them back.
5
+ *
6
+ * Two ids give the UI its shape. `batchId` ties an entry to the request (or job,
7
+ * or scheduled run) it happened inside, so the dashboard can show a request and
8
+ * every query, log, and exception it produced. `familyHash` groups entries that
9
+ * are "the same" — the same SQL shape, the same exception — so recurring things
10
+ * can be counted and collapsed.
11
+ */
12
+ /** Every entry type, in the order the dashboard shows its tabs. */
13
+ export const ENTRY_TYPES = [
14
+ "request",
15
+ "query",
16
+ "exception",
17
+ "log",
18
+ "mail",
19
+ "job",
20
+ "notification",
21
+ "cache",
22
+ "event",
23
+ "schedule",
24
+ ];
25
+ /* --------------------------------- ids ------------------------------------ */
26
+ /** A 32-hex id for an entry. */
27
+ export function newUuid() {
28
+ const bytes = new Uint8Array(16);
29
+ crypto.getRandomValues(bytes);
30
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
31
+ }
32
+ /** A short, stable hash (8 hex) of a string — for `familyHash`. */
33
+ export function familyHash(input) {
34
+ // FNV-1a: tiny, dependency-free, good enough to group like with like.
35
+ let h = 0x811c9dc5;
36
+ for (let i = 0; i < input.length; i++) {
37
+ h ^= input.charCodeAt(i);
38
+ h = Math.imul(h, 0x01000193);
39
+ }
40
+ return (h >>> 0).toString(16).padStart(8, "0");
41
+ }
42
+ /* ------------------------------- serializing ------------------------------ */
43
+ const MAX_STRING = 8_000;
44
+ /**
45
+ * Make an arbitrary value safe to store as JSON: unwrap Errors, truncate long
46
+ * strings, drop functions, and break cycles. Watchers hand user data (request
47
+ * bodies, job payloads, event payloads) straight through, so this is the barrier
48
+ * that keeps one weird value from poisoning the whole store.
49
+ */
50
+ export function jsonSafe(value, seen = new WeakSet()) {
51
+ if (value === null || value === undefined)
52
+ return value ?? null;
53
+ const t = typeof value;
54
+ if (t === "string") {
55
+ const s = value;
56
+ return s.length > MAX_STRING ? `${s.slice(0, MAX_STRING)}… (${s.length} chars)` : s;
57
+ }
58
+ if (t === "number" || t === "boolean")
59
+ return value;
60
+ if (t === "bigint")
61
+ return `${value.toString()}n`;
62
+ if (t === "function" || t === "symbol")
63
+ return `[${t}]`;
64
+ if (value instanceof Error) {
65
+ return {
66
+ name: value.name,
67
+ message: value.message,
68
+ stack: (value.stack ?? "").split("\n").map((l) => l.trim()),
69
+ };
70
+ }
71
+ if (value instanceof Date)
72
+ return value.toISOString();
73
+ if (typeof value === "object") {
74
+ if (seen.has(value))
75
+ return "[Circular]";
76
+ seen.add(value);
77
+ if (Array.isArray(value))
78
+ return value.slice(0, 200).map((v) => jsonSafe(v, seen));
79
+ const out = {};
80
+ for (const [k, v] of Object.entries(value)) {
81
+ out[k] = jsonSafe(v, seen);
82
+ }
83
+ return out;
84
+ }
85
+ return String(value);
86
+ }
87
+ const REDACTED = "[redacted]";
88
+ const SENSITIVE_HEADERS = new Set(["authorization", "cookie", "set-cookie", "proxy-authorization"]);
89
+ /** Redact auth/cookie headers before an entry is stored or shown. */
90
+ export function redactHeaders(headers) {
91
+ const out = {};
92
+ for (const [k, v] of Object.entries(headers)) {
93
+ out[k] = SENSITIVE_HEADERS.has(k.toLowerCase()) ? REDACTED : v;
94
+ }
95
+ return out;
96
+ }
97
+ /** Collapse a SQL statement to its shape, so `id = 1` and `id = 2` group together. */
98
+ export function sqlShape(sql) {
99
+ return sql
100
+ .replace(/\s+/g, " ")
101
+ .replace(/'[^']*'/g, "?")
102
+ .replace(/\b\d+\b/g, "?")
103
+ .trim()
104
+ .toLowerCase();
105
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Who may see the dashboard. It exposes requests, payloads, and stack traces, so
3
+ * it must never be world-readable in production. By default it's open only when
4
+ * `app.debug` is on or the app isn't in production; call `Watch.auth()` to plug
5
+ * in your own check (an admin session, an allow-list) — mirrors Telescope's gate.
6
+ */
7
+ import type { Ctx } from "../core/http/router.js";
8
+ export type WatchGate = (c: Ctx) => boolean | Promise<boolean>;
9
+ export declare const Watch: {
10
+ /**
11
+ * Restrict dashboard access. Return true to allow the request.
12
+ *
13
+ * Watch.auth((c) => auth().check() && auth().user()?.isAdmin);
14
+ */
15
+ auth(fn: WatchGate): void;
16
+ /** Remove a custom gate, reverting to the default. */
17
+ clearAuth(): void;
18
+ };
19
+ /** Whether the current request may see the dashboard. */
20
+ export declare function passesGate(c: Ctx): Promise<boolean>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Who may see the dashboard. It exposes requests, payloads, and stack traces, so
3
+ * it must never be world-readable in production. By default it's open only when
4
+ * `app.debug` is on or the app isn't in production; call `Watch.auth()` to plug
5
+ * in your own check (an admin session, an allow-list) — mirrors Telescope's gate.
6
+ */
7
+ import { config } from "../core/helpers.js";
8
+ let gate;
9
+ export const Watch = {
10
+ /**
11
+ * Restrict dashboard access. Return true to allow the request.
12
+ *
13
+ * Watch.auth((c) => auth().check() && auth().user()?.isAdmin);
14
+ */
15
+ auth(fn) {
16
+ gate = fn;
17
+ },
18
+ /** Remove a custom gate, reverting to the default. */
19
+ clearAuth() {
20
+ gate = undefined;
21
+ },
22
+ };
23
+ /** Allow when debugging, or anywhere that isn't production. */
24
+ function defaultGate() {
25
+ if (config("app.debug", false))
26
+ return true;
27
+ return config("app.env", "production") !== "production";
28
+ }
29
+ /** Whether the current request may see the dashboard. */
30
+ export async function passesGate(c) {
31
+ return gate ? gate(c) : defaultGate();
32
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Keel Watch — a debug dashboard for Keel apps, imported from
3
+ * `@shaferllc/keel/watch`. Register the provider and open `/watch`:
4
+ *
5
+ * import { WatchServiceProvider } from "@shaferllc/keel/watch";
6
+ * app.register(WatchServiceProvider);
7
+ *
8
+ * Lock down who can see it with `Watch.auth()`.
9
+ */
10
+ export { WatchServiceProvider } from "./provider.js";
11
+ export { Watch } from "./gate.js";
12
+ export type { WatchGate } from "./gate.js";
13
+ export { resolveConfig, defaultConfig } from "./config.js";
14
+ export type { WatchConfig } from "./config.js";
15
+ export { MemoryStore, DatabaseStore } from "./store.js";
16
+ export type { EntryStore } from "./store.js";
17
+ export { watchMigration } from "./migration.js";
18
+ export { Recorder } from "./recorder.js";
19
+ export { installWatchers } from "./watchers.js";
20
+ export { ENTRY_TYPES, newUuid, familyHash, jsonSafe, redactHeaders, sqlShape, } from "./entry.js";
21
+ export type { Entry, EntryType, EntryFilter } from "./entry.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Keel Watch — a debug dashboard for Keel apps, imported from
3
+ * `@shaferllc/keel/watch`. Register the provider and open `/watch`:
4
+ *
5
+ * import { WatchServiceProvider } from "@shaferllc/keel/watch";
6
+ * app.register(WatchServiceProvider);
7
+ *
8
+ * Lock down who can see it with `Watch.auth()`.
9
+ */
10
+ export { WatchServiceProvider } from "./provider.js";
11
+ export { Watch } from "./gate.js";
12
+ export { resolveConfig, defaultConfig } from "./config.js";
13
+ export { MemoryStore, DatabaseStore } from "./store.js";
14
+ export { watchMigration } from "./migration.js";
15
+ export { Recorder } from "./recorder.js";
16
+ export { installWatchers } from "./watchers.js";
17
+ export { ENTRY_TYPES, newUuid, familyHash, jsonSafe, redactHeaders, sqlShape, } from "./entry.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The `watch_entries` table. Contributed by the provider via `this.migrations()`
3
+ * and run by `keel migrate` alongside the app's own — the same path any package
4
+ * ships schema on.
5
+ */
6
+ import type { Migration } from "../core/migrations.js";
7
+ export declare function watchMigration(table?: string): Migration;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The `watch_entries` table. Contributed by the provider via `this.migrations()`
3
+ * and run by `keel migrate` alongside the app's own — the same path any package
4
+ * ships schema on.
5
+ */
6
+ export function watchMigration(table = "watch_entries") {
7
+ return {
8
+ name: `watch_00_create_${table}`,
9
+ up: (schema) => schema.createTable(table, (t) => {
10
+ t.string("uuid").unique();
11
+ t.string("batch_id");
12
+ t.string("type", 32);
13
+ t.string("family_hash").nullable();
14
+ t.text("content");
15
+ t.text("tags");
16
+ t.bigInteger("created_at");
17
+ }),
18
+ down: (schema) => schema.dropTable(table),
19
+ };
20
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Keel Watch — a Telescope-style debug dashboard, shipped as a Keel package.
3
+ *
4
+ * One line in `bootstrap/providers.ts` turns it on:
5
+ * app.register(WatchServiceProvider)
6
+ *
7
+ * It's the reference consumer of the package system: it merges its own config,
8
+ * contributes a migration, mounts routes and a bundled SPA, and adds a console
9
+ * command — all through `PackageProvider` helpers, touching no framework
10
+ * internals. The watchers observe the instrumentation event stream; they patch
11
+ * nothing, so installing Watch changes no behaviour, only visibility.
12
+ */
13
+ import { PackageProvider } from "../core/package.js";
14
+ export declare class WatchServiceProvider extends PackageProvider {
15
+ readonly name = "watch";
16
+ private config;
17
+ private store;
18
+ private teardown?;
19
+ register(): void;
20
+ boot(): void;
21
+ shutdown(): void;
22
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Keel Watch — a Telescope-style debug dashboard, shipped as a Keel package.
3
+ *
4
+ * One line in `bootstrap/providers.ts` turns it on:
5
+ * app.register(WatchServiceProvider)
6
+ *
7
+ * It's the reference consumer of the package system: it merges its own config,
8
+ * contributes a migration, mounts routes and a bundled SPA, and adds a console
9
+ * command — all through `PackageProvider` helpers, touching no framework
10
+ * internals. The watchers observe the instrumentation event stream; they patch
11
+ * nothing, so installing Watch changes no behaviour, only visibility.
12
+ */
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+ import { PackageProvider } from "../core/package.js";
16
+ import { resolveConfig, defaultConfig } from "./config.js";
17
+ import { MemoryStore, DatabaseStore } from "./store.js";
18
+ import { watchMigration } from "./migration.js";
19
+ import { Recorder } from "./recorder.js";
20
+ import { installWatchers } from "./watchers.js";
21
+ import { registerWatchRoutes } from "./routes.js";
22
+ import { pruneCommand } from "./prune.js";
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ export class WatchServiceProvider extends PackageProvider {
25
+ name = "watch";
26
+ config;
27
+ store;
28
+ teardown;
29
+ register() {
30
+ this.mergeConfig("watch", defaultConfig);
31
+ this.config = resolveConfig();
32
+ this.store =
33
+ this.config.storage === "memory"
34
+ ? new MemoryStore(this.config.limit * 10)
35
+ : new DatabaseStore(this.config.table, this.config.connection);
36
+ // Schema (run by `keel migrate`), a publishable config stub, and the CLI.
37
+ this.migrations([watchMigration(this.config.table)]);
38
+ this.publishes({ [join(here, "watch.config.stub")]: "config/watch.ts" }, "watch-config");
39
+ this.commands([pruneCommand(this.store, this.config)]);
40
+ }
41
+ boot() {
42
+ if (!this.config.enabled)
43
+ return;
44
+ // Observe the instrumentation stream.
45
+ this.teardown = installWatchers(new Recorder(this.store, this.config), this.config);
46
+ // Serve the dashboard: the bundled SPA, the JSON API, and the shell.
47
+ const base = "/" + this.config.path.replace(/^\/|\/$/g, "");
48
+ const assetsUrl = `${base}/assets`;
49
+ this.assets(`${this.config.path}/assets`, join(here, "ui/dist"), { maxAge: 3600 });
50
+ this.routes((r) => registerWatchRoutes(r, this.store, this.config, { base, assetsUrl }), {
51
+ prefix: this.config.path,
52
+ as: "watch",
53
+ });
54
+ }
55
+ shutdown() {
56
+ this.teardown?.();
57
+ }
58
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `keel watch:prune` — drop entries past the retention window. Contributed as a
3
+ * package command; schedule it (or run it from cron) to keep the table small.
4
+ * Note it only reaches persisted storage: with the in-memory store the ring
5
+ * buffer bounds itself per process, so there's nothing for a separate CLI
6
+ * process to prune.
7
+ */
8
+ import type { PackageCommand } from "../core/package.js";
9
+ import type { EntryStore } from "./store.js";
10
+ import type { WatchConfig } from "./config.js";
11
+ export declare function pruneCommand(store: EntryStore, config: WatchConfig): PackageCommand;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `keel watch:prune` — drop entries past the retention window. Contributed as a
3
+ * package command; schedule it (or run it from cron) to keep the table small.
4
+ * Note it only reaches persisted storage: with the in-memory store the ring
5
+ * buffer bounds itself per process, so there's nothing for a separate CLI
6
+ * process to prune.
7
+ */
8
+ import { defineCommand, flag } from "../core/console.js";
9
+ export function pruneCommand(store, config) {
10
+ return defineCommand({
11
+ name: "watch:prune",
12
+ description: "Delete Watch entries older than the retention window",
13
+ flags: { hours: flag.number({ description: "override the retention window (hours)" }) },
14
+ async run({ flags, ui }) {
15
+ // A typed flag, so `--hours nonsense` is now a usage error rather than a NaN
16
+ // that silently prunes everything.
17
+ const hours = flags.hours ?? config.retentionHours;
18
+ const removed = await store.prune(Date.now() - hours * 3_600_000);
19
+ ui.success(`Pruned ${removed} Watch entr${removed === 1 ? "y" : "ies"} older than ${hours}h.`);
20
+ },
21
+ });
22
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The recorder turns a watcher's observation into a stored `Entry`. It applies
3
+ * the config gates (enabled, per-type toggle, sampling), stamps the entry with
4
+ * the current request/batch id, makes the content JSON-safe, and persists it —
5
+ * fire-and-forget, so recording never blocks or breaks the thing it's watching.
6
+ */
7
+ import type { EntryType } from "./entry.js";
8
+ import type { EntryStore } from "./store.js";
9
+ import type { WatchConfig } from "./config.js";
10
+ export interface RecordOptions {
11
+ /** Force the batch id (else the current request id, else a fresh one). */
12
+ batchId?: string;
13
+ /** Group this entry with like ones. */
14
+ familyHash?: string;
15
+ }
16
+ export declare class Recorder {
17
+ private store;
18
+ private config;
19
+ constructor(store: EntryStore, config: WatchConfig);
20
+ /** Whether a given entry type should be recorded at all. */
21
+ enabledFor(type: EntryType): boolean;
22
+ /** Record one entry. A no-op when the type is disabled or sampled out. */
23
+ record(type: EntryType, content: Record<string, unknown>, tags?: string[], options?: RecordOptions): void;
24
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The recorder turns a watcher's observation into a stored `Entry`. It applies
3
+ * the config gates (enabled, per-type toggle, sampling), stamps the entry with
4
+ * the current request/batch id, makes the content JSON-safe, and persists it —
5
+ * fire-and-forget, so recording never blocks or breaks the thing it's watching.
6
+ */
7
+ import { currentRequestId } from "../core/instrumentation.js";
8
+ import { newUuid, jsonSafe } from "./entry.js";
9
+ export class Recorder {
10
+ store;
11
+ config;
12
+ constructor(store, config) {
13
+ this.store = store;
14
+ this.config = config;
15
+ }
16
+ /** Whether a given entry type should be recorded at all. */
17
+ enabledFor(type) {
18
+ return this.config.enabled && this.config.watchers[type];
19
+ }
20
+ /** Record one entry. A no-op when the type is disabled or sampled out. */
21
+ record(type, content, tags = [], options = {}) {
22
+ if (!this.enabledFor(type))
23
+ return;
24
+ if (this.config.sampling < 1 && Math.random() > this.config.sampling)
25
+ return;
26
+ const entry = {
27
+ uuid: newUuid(),
28
+ batchId: options.batchId ?? currentRequestId() ?? newUuid(),
29
+ type,
30
+ ...(options.familyHash ? { familyHash: options.familyHash } : {}),
31
+ content: jsonSafe(content),
32
+ tags,
33
+ createdAt: Date.now(),
34
+ };
35
+ void Promise.resolve(this.store.record([entry])).catch(() => {
36
+ // Persistence failures must never surface into the watched request.
37
+ });
38
+ }
39
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The dashboard's HTTP surface: a small JSON API the SPA polls, and the SPA
3
+ * shell itself. Every route is behind the gate — the API included, so entries
4
+ * can't be read by anyone the dashboard wouldn't show them to.
5
+ *
6
+ * Registered under the configured prefix by the provider, so paths here are
7
+ * relative to it ("/", "/api/entries", …).
8
+ */
9
+ import type { Router } from "../core/http/router.js";
10
+ import type { EntryStore } from "./store.js";
11
+ import type { WatchConfig } from "./config.js";
12
+ import { type ShellOptions } from "./ui-shell.js";
13
+ export declare function registerWatchRoutes(r: Router, store: EntryStore, config: WatchConfig, shell: ShellOptions): void;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The dashboard's HTTP surface: a small JSON API the SPA polls, and the SPA
3
+ * shell itself. Every route is behind the gate — the API included, so entries
4
+ * can't be read by anyone the dashboard wouldn't show them to.
5
+ *
6
+ * Registered under the configured prefix by the provider, so paths here are
7
+ * relative to it ("/", "/api/entries", …).
8
+ */
9
+ import { passesGate } from "./gate.js";
10
+ import { dashboardHtml } from "./ui-shell.js";
11
+ export function registerWatchRoutes(r, store, config, shell) {
12
+ /** Wrap a JSON handler in the gate. */
13
+ const guarded = (handler) => async (c) => {
14
+ if (!(await passesGate(c)))
15
+ return c.json({ error: "Forbidden" }, 403);
16
+ return handler(c);
17
+ };
18
+ // Per-type counts for the tab badges.
19
+ r.get("/api/counts", guarded(async (c) => c.json({ counts: await store.counts() }))).name("counts");
20
+ // A page of entries, filtered by type/tag and keyset-paginated by `before`.
21
+ r.get("/api/entries", guarded(async (c) => {
22
+ const type = c.req.query("type");
23
+ const tag = c.req.query("tag") || undefined;
24
+ const before = c.req.query("before") ? Number(c.req.query("before")) : undefined;
25
+ const limit = c.req.query("limit") ? Number(c.req.query("limit")) : config.limit;
26
+ const entries = await store.list({
27
+ ...(type ? { type } : {}),
28
+ ...(tag ? { tag } : {}),
29
+ ...(before ? { before } : {}),
30
+ limit,
31
+ });
32
+ return c.json({ entries });
33
+ })).name("entries");
34
+ // One entry, plus the other entries in its batch (the request it belonged to).
35
+ r.get("/api/entries/:uuid", guarded(async (c) => {
36
+ const entry = await store.get(c.req.param("uuid"));
37
+ if (!entry)
38
+ return c.json({ error: "Not found" }, 404);
39
+ const related = (await store.batch(entry.batchId)).filter((e) => e.uuid !== entry.uuid);
40
+ return c.json({ entry, related });
41
+ })).name("entry");
42
+ // Every entry in a batch — a request and all it produced.
43
+ r.get("/api/batch/:batchId", guarded(async (c) => c.json({ entries: await store.batch(c.req.param("batchId")) }))).name("batch");
44
+ // Wipe the store from the dashboard's "Clear" button.
45
+ r.delete("/api/entries", guarded(async (c) => {
46
+ await store.clear();
47
+ return c.json({ ok: true });
48
+ })).name("clear");
49
+ // The SPA shell. Deep links are hash-based, so this one route serves them all.
50
+ r.get("/", async (c) => {
51
+ if (!(await passesGate(c)))
52
+ return c.text("Forbidden", 403);
53
+ return c.html(dashboardHtml(shell));
54
+ }).name("dashboard");
55
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Where recorded entries live. Two implementations behind one interface:
3
+ *
4
+ * - `MemoryStore` — a per-process ring buffer. Zero setup, great for a single
5
+ * dev process or the edge; entries vanish on restart.
6
+ * - `DatabaseStore` — a `watch_entries` table via any registered connection.
7
+ * Survives restarts and is shared across processes; needs `keel migrate`.
8
+ *
9
+ * The store's own reads and writes hit the database like anything else, so they
10
+ * would show up in the Query watcher — an infinite hall of mirrors. The watcher
11
+ * filters them out by table name (see `watchers.ts`); the store just does its job.
12
+ */
13
+ import type { Entry, EntryFilter, EntryType } from "./entry.js";
14
+ export interface EntryStore {
15
+ /** Persist a batch of entries. */
16
+ record(entries: Entry[]): Promise<void>;
17
+ /** One entry by id. */
18
+ get(uuid: string): Promise<Entry | undefined>;
19
+ /** Entries matching a filter, newest first. */
20
+ list(filter: EntryFilter): Promise<Entry[]>;
21
+ /** Every entry in one batch (a request and its children), newest first. */
22
+ batch(batchId: string): Promise<Entry[]>;
23
+ /** How many entries of each type — for the dashboard's tab counts. */
24
+ counts(): Promise<Record<EntryType, number>>;
25
+ /** Delete entries created before `timestamp`. Returns how many. */
26
+ prune(timestamp: number): Promise<number>;
27
+ /** Delete everything. */
28
+ clear(): Promise<void>;
29
+ }
30
+ export declare class MemoryStore implements EntryStore {
31
+ private cap;
32
+ private entries;
33
+ constructor(cap?: number);
34
+ record(entries: Entry[]): Promise<void>;
35
+ get(uuid: string): Promise<Entry | undefined>;
36
+ list(filter: EntryFilter): Promise<Entry[]>;
37
+ batch(batchId: string): Promise<Entry[]>;
38
+ counts(): Promise<Record<EntryType, number>>;
39
+ prune(timestamp: number): Promise<number>;
40
+ clear(): Promise<void>;
41
+ }
42
+ export declare class DatabaseStore implements EntryStore {
43
+ private table;
44
+ private connectionName?;
45
+ constructor(table: string, connectionName?: string | undefined);
46
+ private conn;
47
+ record(entries: Entry[]): Promise<void>;
48
+ get(uuid: string): Promise<Entry | undefined>;
49
+ list(filter: EntryFilter): Promise<Entry[]>;
50
+ batch(batchId: string): Promise<Entry[]>;
51
+ counts(): Promise<Record<EntryType, number>>;
52
+ prune(timestamp: number): Promise<number>;
53
+ clear(): Promise<void>;
54
+ }