@shaferllc/keel 0.66.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/AGENTS.md +169 -0
  2. package/README.md +44 -6
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/api/api.config.stub +9 -0
  5. package/dist/api/config.d.ts +13 -0
  6. package/dist/api/config.js +14 -0
  7. package/dist/api/index.d.ts +16 -0
  8. package/dist/api/index.js +13 -0
  9. package/dist/api/provider.d.ts +10 -0
  10. package/dist/api/provider.js +17 -0
  11. package/dist/api/query.d.ts +35 -0
  12. package/dist/api/query.js +42 -0
  13. package/dist/api/resource.d.ts +91 -0
  14. package/dist/api/resource.js +188 -0
  15. package/dist/core/application.d.ts +5 -5
  16. package/dist/core/application.js +8 -2
  17. package/dist/core/cache.d.ts +81 -5
  18. package/dist/core/cache.js +188 -23
  19. package/dist/core/cli/stubs.d.ts +26 -0
  20. package/dist/core/cli/stubs.js +225 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/events.d.ts +129 -5
  32. package/dist/core/events.js +165 -7
  33. package/dist/core/health.d.ts +141 -0
  34. package/dist/core/health.js +226 -0
  35. package/dist/core/helpers.d.ts +9 -3
  36. package/dist/core/helpers.js +11 -3
  37. package/dist/core/http/kernel.d.ts +2 -0
  38. package/dist/core/http/kernel.js +48 -0
  39. package/dist/core/http/router.d.ts +5 -5
  40. package/dist/core/http/router.js +5 -5
  41. package/dist/core/i18n.d.ts +162 -0
  42. package/dist/core/i18n.js +472 -0
  43. package/dist/core/index.d.ts +39 -13
  44. package/dist/core/index.js +19 -6
  45. package/dist/core/instrumentation.d.ts +113 -0
  46. package/dist/core/instrumentation.js +52 -0
  47. package/dist/core/lock.d.ts +139 -0
  48. package/dist/core/lock.js +215 -0
  49. package/dist/core/logger.d.ts +89 -4
  50. package/dist/core/logger.js +167 -22
  51. package/dist/core/mail.d.ts +128 -7
  52. package/dist/core/mail.js +264 -16
  53. package/dist/core/notification.js +10 -1
  54. package/dist/core/package.d.ts +120 -0
  55. package/dist/core/package.js +169 -0
  56. package/dist/core/pages.d.ts +108 -0
  57. package/dist/core/pages.js +199 -0
  58. package/dist/core/queue.d.ts +134 -9
  59. package/dist/core/queue.js +325 -14
  60. package/dist/core/repl.d.ts +33 -0
  61. package/dist/core/repl.js +88 -0
  62. package/dist/core/scheduler.js +6 -0
  63. package/dist/core/social.d.ts +4 -4
  64. package/dist/core/social.js +4 -4
  65. package/dist/core/storage.d.ts +159 -6
  66. package/dist/core/storage.js +299 -7
  67. package/dist/core/telemetry.d.ts +208 -0
  68. package/dist/core/telemetry.js +383 -0
  69. package/dist/core/template.d.ts +2 -3
  70. package/dist/core/template.js +2 -3
  71. package/dist/core/testing.d.ts +170 -1
  72. package/dist/core/testing.js +504 -2
  73. package/dist/db/d1.js +13 -0
  74. package/dist/db/pg.d.ts +13 -0
  75. package/dist/db/pg.js +46 -4
  76. package/dist/mcp/server.d.ts +19 -0
  77. package/dist/mcp/server.js +355 -0
  78. package/dist/openapi/config.d.ts +28 -0
  79. package/dist/openapi/config.js +25 -0
  80. package/dist/openapi/doc.d.ts +40 -0
  81. package/dist/openapi/doc.js +20 -0
  82. package/dist/openapi/export.d.ts +8 -0
  83. package/dist/openapi/export.js +19 -0
  84. package/dist/openapi/gate.d.ts +15 -0
  85. package/dist/openapi/gate.js +27 -0
  86. package/dist/openapi/index.d.ts +19 -0
  87. package/dist/openapi/index.js +15 -0
  88. package/dist/openapi/openapi.config.stub +29 -0
  89. package/dist/openapi/provider.d.ts +18 -0
  90. package/dist/openapi/provider.js +35 -0
  91. package/dist/openapi/routes.d.ts +9 -0
  92. package/dist/openapi/routes.js +23 -0
  93. package/dist/openapi/spec.d.ts +23 -0
  94. package/dist/openapi/spec.js +132 -0
  95. package/dist/openapi/ui.d.ts +8 -0
  96. package/dist/openapi/ui.js +31 -0
  97. package/dist/openapi/zod.d.ts +12 -0
  98. package/dist/openapi/zod.js +46 -0
  99. package/dist/watch/config.d.ts +33 -0
  100. package/dist/watch/config.js +38 -0
  101. package/dist/watch/entry.d.ts +53 -0
  102. package/dist/watch/entry.js +105 -0
  103. package/dist/watch/gate.d.ts +20 -0
  104. package/dist/watch/gate.js +32 -0
  105. package/dist/watch/index.d.ts +21 -0
  106. package/dist/watch/index.js +17 -0
  107. package/dist/watch/migration.d.ts +7 -0
  108. package/dist/watch/migration.js +20 -0
  109. package/dist/watch/provider.d.ts +22 -0
  110. package/dist/watch/provider.js +58 -0
  111. package/dist/watch/prune.d.ts +11 -0
  112. package/dist/watch/prune.js +20 -0
  113. package/dist/watch/recorder.d.ts +24 -0
  114. package/dist/watch/recorder.js +39 -0
  115. package/dist/watch/routes.d.ts +13 -0
  116. package/dist/watch/routes.js +55 -0
  117. package/dist/watch/store.d.ts +54 -0
  118. package/dist/watch/store.js +158 -0
  119. package/dist/watch/ui/dist/watch.css +1 -0
  120. package/dist/watch/ui/dist/watch.js +555 -0
  121. package/dist/watch/ui-shell.d.ts +12 -0
  122. package/dist/watch/ui-shell.js +24 -0
  123. package/dist/watch/watch.config.stub +47 -0
  124. package/dist/watch/watchers.d.ts +12 -0
  125. package/dist/watch/watchers.js +156 -0
  126. package/docs/ai-manifest.json +3206 -0
  127. package/docs/ai.md +128 -0
  128. package/docs/api-resources.md +118 -0
  129. package/docs/architecture.md +331 -0
  130. package/docs/authentication.md +453 -0
  131. package/docs/authorization.md +167 -0
  132. package/docs/broadcasting.md +137 -0
  133. package/docs/broker.md +500 -0
  134. package/docs/cache.md +558 -0
  135. package/docs/configuration.md +385 -0
  136. package/docs/console.md +536 -0
  137. package/docs/container.md +467 -0
  138. package/docs/controllers.md +265 -0
  139. package/docs/cors.md +51 -0
  140. package/docs/database.md +631 -0
  141. package/docs/debugging.md +129 -0
  142. package/docs/decorators.md +127 -0
  143. package/docs/errors.md +395 -0
  144. package/docs/events.md +496 -0
  145. package/docs/examples/architecture-app.ts +27 -0
  146. package/docs/examples/authentication.ts +61 -0
  147. package/docs/examples/authorization.ts +79 -0
  148. package/docs/examples/broadcasting.ts +60 -0
  149. package/docs/examples/broker-cache-validate.ts +34 -0
  150. package/docs/examples/broker-fault-tolerance.ts +29 -0
  151. package/docs/examples/broker-middleware.ts +27 -0
  152. package/docs/examples/broker.ts +203 -0
  153. package/docs/examples/cache.ts +222 -0
  154. package/docs/examples/configuration.ts +121 -0
  155. package/docs/examples/console.ts +134 -0
  156. package/docs/examples/container.ts +134 -0
  157. package/docs/examples/controllers.ts +86 -0
  158. package/docs/examples/database.ts +208 -0
  159. package/docs/examples/debugging.ts +41 -0
  160. package/docs/examples/decorators.ts +40 -0
  161. package/docs/examples/errors.ts +121 -0
  162. package/docs/examples/events.ts +204 -0
  163. package/docs/examples/factories.ts +84 -0
  164. package/docs/examples/hashing.ts +71 -0
  165. package/docs/examples/health.ts +94 -0
  166. package/docs/examples/helpers.ts +171 -0
  167. package/docs/examples/hooks.ts +54 -0
  168. package/docs/examples/i18n.ts +117 -0
  169. package/docs/examples/inertia.ts +81 -0
  170. package/docs/examples/locks.ts +120 -0
  171. package/docs/examples/logger.ts +166 -0
  172. package/docs/examples/mail.ts +263 -0
  173. package/docs/examples/middleware.ts +119 -0
  174. package/docs/examples/migrations.ts +126 -0
  175. package/docs/examples/models.ts +239 -0
  176. package/docs/examples/notification.ts +124 -0
  177. package/docs/examples/pages.ts +82 -0
  178. package/docs/examples/providers.ts +123 -0
  179. package/docs/examples/queues.ts +254 -0
  180. package/docs/examples/rate-limiting.ts +42 -0
  181. package/docs/examples/redis.ts +99 -0
  182. package/docs/examples/request-response.ts +197 -0
  183. package/docs/examples/routing.ts +186 -0
  184. package/docs/examples/scheduling.ts +62 -0
  185. package/docs/examples/sessions.ts +102 -0
  186. package/docs/examples/static-files.ts +63 -0
  187. package/docs/examples/storage.ts +132 -0
  188. package/docs/examples/telemetry.ts +127 -0
  189. package/docs/examples/templates.ts +58 -0
  190. package/docs/examples/testing.ts +215 -0
  191. package/docs/examples/transformer.ts +141 -0
  192. package/docs/examples/transformers.ts +49 -0
  193. package/docs/examples/url-builder.ts +86 -0
  194. package/docs/examples/validation.ts +102 -0
  195. package/docs/examples/views.tsx +62 -0
  196. package/docs/examples/vite.ts +106 -0
  197. package/docs/factories.md +166 -0
  198. package/docs/getting-started.md +290 -0
  199. package/docs/hashing.md +259 -0
  200. package/docs/health.md +225 -0
  201. package/docs/helpers.md +347 -0
  202. package/docs/hono.md +186 -0
  203. package/docs/hooks.md +118 -0
  204. package/docs/i18n.md +302 -0
  205. package/docs/inertia.md +241 -0
  206. package/docs/locks.md +323 -0
  207. package/docs/logger.md +436 -0
  208. package/docs/mail.md +751 -0
  209. package/docs/middleware.md +425 -0
  210. package/docs/migrations.md +476 -0
  211. package/docs/models.md +810 -0
  212. package/docs/notifications.md +474 -0
  213. package/docs/openapi.md +111 -0
  214. package/docs/packages.md +118 -0
  215. package/docs/pages.md +217 -0
  216. package/docs/providers.md +363 -0
  217. package/docs/queues.md +679 -0
  218. package/docs/rate-limiting.md +155 -0
  219. package/docs/redis.md +178 -0
  220. package/docs/request-response.md +953 -0
  221. package/docs/routing.md +804 -0
  222. package/docs/scheduling.md +110 -0
  223. package/docs/security.md +85 -0
  224. package/docs/sessions.md +354 -0
  225. package/docs/social-auth.md +174 -0
  226. package/docs/static-files.md +211 -0
  227. package/docs/storage.md +452 -0
  228. package/docs/telemetry.md +263 -0
  229. package/docs/templates.md +314 -0
  230. package/docs/testing.md +376 -0
  231. package/docs/transformers.md +381 -0
  232. package/docs/url-builder.md +295 -0
  233. package/docs/validation.md +288 -0
  234. package/docs/views.md +267 -0
  235. package/docs/vite.md +434 -0
  236. package/docs/watch.md +118 -0
  237. package/llms-full.txt +19816 -0
  238. package/llms.txt +127 -0
  239. package/package.json +42 -7
@@ -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,20 @@
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
+ export function pruneCommand(store, config) {
9
+ return {
10
+ name: "watch:prune",
11
+ description: "Delete Watch entries older than the retention window",
12
+ configure: (cmd) => cmd.option("--hours <hours>", "override the retention window (hours)"),
13
+ action: async (opts) => {
14
+ const hours = opts.hours ? Number(opts.hours) : config.retentionHours;
15
+ const before = Date.now() - hours * 3_600_000;
16
+ const removed = await store.prune(before);
17
+ console.log(`Pruned ${removed} Watch entr${removed === 1 ? "y" : "ies"} older than ${hours}h.`);
18
+ },
19
+ };
20
+ }
@@ -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
+ }
@@ -0,0 +1,158 @@
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 { connection } from "../core/database.js";
14
+ import { ENTRY_TYPES } from "./entry.js";
15
+ /* -------------------------------- memory ---------------------------------- */
16
+ export class MemoryStore {
17
+ cap;
18
+ entries = [];
19
+ constructor(cap = 1000) {
20
+ this.cap = cap;
21
+ }
22
+ async record(entries) {
23
+ // newest first; trim the oldest past the cap
24
+ this.entries.unshift(...entries);
25
+ if (this.entries.length > this.cap)
26
+ this.entries.length = this.cap;
27
+ }
28
+ async get(uuid) {
29
+ return this.entries.find((e) => e.uuid === uuid);
30
+ }
31
+ async list(filter) {
32
+ let rows = this.entries;
33
+ if (filter.type)
34
+ rows = rows.filter((e) => e.type === filter.type);
35
+ if (filter.tag)
36
+ rows = rows.filter((e) => e.tags.includes(filter.tag));
37
+ if (filter.batchId)
38
+ rows = rows.filter((e) => e.batchId === filter.batchId);
39
+ if (filter.before != null)
40
+ rows = rows.filter((e) => e.createdAt < filter.before);
41
+ return rows.slice(0, filter.limit ?? 100);
42
+ }
43
+ async batch(batchId) {
44
+ return this.entries.filter((e) => e.batchId === batchId);
45
+ }
46
+ async counts() {
47
+ const counts = emptyCounts();
48
+ for (const e of this.entries)
49
+ counts[e.type]++;
50
+ return counts;
51
+ }
52
+ async prune(timestamp) {
53
+ const before = this.entries.length;
54
+ this.entries = this.entries.filter((e) => e.createdAt >= timestamp);
55
+ return before - this.entries.length;
56
+ }
57
+ async clear() {
58
+ this.entries = [];
59
+ }
60
+ }
61
+ export class DatabaseStore {
62
+ table;
63
+ connectionName;
64
+ constructor(table, connectionName) {
65
+ this.table = table;
66
+ this.connectionName = connectionName;
67
+ }
68
+ conn() {
69
+ return connection(this.connectionName);
70
+ }
71
+ async record(entries) {
72
+ const conn = this.conn();
73
+ for (const e of entries) {
74
+ await conn.write(`INSERT INTO ${this.table} (uuid, batch_id, type, family_hash, content, tags, created_at)
75
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
76
+ e.uuid,
77
+ e.batchId,
78
+ e.type,
79
+ e.familyHash ?? null,
80
+ JSON.stringify(e.content),
81
+ // tags stored comma-wrapped (",a,b,") so `LIKE '%,tag,%'` matches exactly
82
+ e.tags.length ? `,${e.tags.join(",")},` : "",
83
+ e.createdAt,
84
+ ]);
85
+ }
86
+ }
87
+ async get(uuid) {
88
+ const rows = (await this.conn().select(`SELECT * FROM ${this.table} WHERE uuid = ? LIMIT 1`, [uuid]));
89
+ return rows[0] ? hydrate(rows[0]) : undefined;
90
+ }
91
+ async list(filter) {
92
+ const where = [];
93
+ const bindings = [];
94
+ if (filter.type) {
95
+ where.push("type = ?");
96
+ bindings.push(filter.type);
97
+ }
98
+ if (filter.batchId) {
99
+ where.push("batch_id = ?");
100
+ bindings.push(filter.batchId);
101
+ }
102
+ if (filter.tag) {
103
+ where.push("tags LIKE ?");
104
+ bindings.push(`%,${filter.tag},%`);
105
+ }
106
+ if (filter.before != null) {
107
+ where.push("created_at < ?");
108
+ bindings.push(filter.before);
109
+ }
110
+ const clause = where.length ? `WHERE ${where.join(" AND ")}` : "";
111
+ const rows = (await this.conn().select(`SELECT * FROM ${this.table} ${clause} ORDER BY created_at DESC LIMIT ${Number(filter.limit ?? 100)}`, bindings));
112
+ return rows.map(hydrate);
113
+ }
114
+ async batch(batchId) {
115
+ const rows = (await this.conn().select(`SELECT * FROM ${this.table} WHERE batch_id = ? ORDER BY created_at ASC`, [batchId]));
116
+ return rows.map(hydrate);
117
+ }
118
+ async counts() {
119
+ const rows = (await this.conn().select(`SELECT type, COUNT(*) AS n FROM ${this.table} GROUP BY type`, []));
120
+ const counts = emptyCounts();
121
+ for (const r of rows) {
122
+ if (r.type in counts)
123
+ counts[r.type] = Number(r.n);
124
+ }
125
+ return counts;
126
+ }
127
+ async prune(timestamp) {
128
+ const result = await this.conn().write(`DELETE FROM ${this.table} WHERE created_at < ?`, [timestamp]);
129
+ return result.rowsAffected;
130
+ }
131
+ async clear() {
132
+ await this.conn().write(`DELETE FROM ${this.table}`, []);
133
+ }
134
+ }
135
+ /* -------------------------------- helpers --------------------------------- */
136
+ function emptyCounts() {
137
+ return Object.fromEntries(ENTRY_TYPES.map((t) => [t, 0]));
138
+ }
139
+ /** Turn a stored row back into an Entry. */
140
+ function hydrate(row) {
141
+ return {
142
+ uuid: row.uuid,
143
+ batchId: row.batch_id,
144
+ type: row.type,
145
+ ...(row.family_hash ? { familyHash: row.family_hash } : {}),
146
+ content: safeParse(row.content),
147
+ tags: row.tags ? row.tags.split(",").filter(Boolean) : [],
148
+ createdAt: Number(row.created_at),
149
+ };
150
+ }
151
+ function safeParse(json) {
152
+ try {
153
+ return JSON.parse(json);
154
+ }
155
+ catch {
156
+ return { raw: json };
157
+ }
158
+ }
@@ -0,0 +1 @@
1
+ :root{--bg: #f7f8fa;--panel: #ffffff;--panel-2: #f0f2f5;--border: #e2e6ec;--text: #1e2430;--muted: #6b7686;--accent: #2563eb;--accent-soft: #dbe6ff;--ok: #16a34a;--warn: #d97706;--err: #dc2626;--chip: #eef1f6;--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace}@media(prefers-color-scheme:dark){:root{--bg: #0b0f17;--panel: #121824;--panel-2: #0e131d;--border: #222b3a;--text: #e6eaf1;--muted: #8a94a6;--accent: #5b8dff;--accent-soft: #1b2740;--ok: #34d399;--warn: #fbbf24;--err: #f87171;--chip: #1a2333}}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}.app{display:grid;grid-template-columns:232px 1fr;min-height:100vh}.sidebar{background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;padding:16px 12px;gap:4px;position:sticky;top:0;height:100vh}.brand{font-weight:700;font-size:16px;padding:4px 8px 14px;letter-spacing:.2px}.brand .anchor{margin-right:4px}.sidebar nav{display:flex;flex-direction:column;gap:2px;flex:1}.tab{display:flex;align-items:center;justify-content:space-between;border:0;background:transparent;color:var(--muted);padding:8px 10px;border-radius:8px;cursor:pointer;font-size:13.5px;text-align:left}.tab:hover{background:var(--panel-2);color:var(--text)}.tab.active{background:var(--accent-soft);color:var(--accent);font-weight:600}.count{font-variant-numeric:tabular-nums;font-size:12px;background:var(--chip);color:var(--muted);border-radius:999px;padding:1px 8px;min-width:22px;text-align:center}.tab.active .count{background:var(--accent);color:#fff}.controls{display:flex;align-items:center;justify-content:space-between;padding:12px 8px 2px;border-top:1px solid var(--border);margin-top:8px}.live{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:13px;cursor:pointer}.clear{border:1px solid var(--border);background:var(--panel-2);color:var(--muted);border-radius:7px;padding:5px 12px;cursor:pointer;font-size:12.5px}.clear:hover{color:var(--err);border-color:var(--err)}.content{padding:22px 26px;min-width:0}.head{display:flex;align-items:center;gap:12px;margin-bottom:16px}.head h1{font-size:18px;margin:0;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.back,.chip.removable{border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:7px;padding:5px 11px;cursor:pointer;font-size:13px}.back:hover{color:var(--accent);border-color:var(--accent)}.empty{color:var(--muted);padding:40px 4px}.list{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:10px;overflow:hidden}.list tr{border-bottom:1px solid var(--border);cursor:pointer}.list tr:last-child{border-bottom:0}.list tr:hover{background:var(--panel-2)}.list td{padding:10px 14px;vertical-align:middle}.summary{display:flex;flex-direction:column;gap:5px;min-width:0}.summary .text{font-family:var(--mono);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:62vw}.tags{display:flex;gap:5px;flex-wrap:wrap}.chip{background:var(--chip);color:var(--muted);border-radius:999px;padding:1px 9px;font-size:11px;cursor:pointer;white-space:nowrap}.chip:hover{color:var(--accent)}.metacol,.timecol{text-align:right;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap}.badge{color:var(--accent);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;width:1%}.detailtags{margin-bottom:14px}.json{background:var(--panel-2);border:1px solid var(--border);border-radius:10px;padding:16px;font-family:var(--mono);font-size:12.5px;line-height:1.6;overflow-x:auto;white-space:pre-wrap;word-break:break-word}.related{margin-top:24px}.related h2{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:0 0 10px}@media(max-width:720px){.app{grid-template-columns:1fr}.sidebar{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}.sidebar nav{flex-direction:row;flex-wrap:wrap}.summary .text{max-width:70vw}}