Public: anyone can read this; the crew can change it.

What is flowing: the sources and kinds the hub has seen, on the page, in the API and in MCP #70

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/relay:scout/47-what-is-flowing-the-sources-and-kinds-th → main 1 commit fork

  • Merged. scout/47-what-is-flowing-the-sources-and-kinds-th was merged into main as 460b31c.

Review

Sign in to review this change.
9 files changed +344 −6
README.md +9 −1
@@ -137,8 +137,16 @@ curl https://<app>/api/events?source=shop&kind=order&limit=20
137137 curl https://<app>/api/events/1
138138 curl -X POST https://<app>/api/events -H 'content-type: application/json' \
139139 -d '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in.","source":"shop"}'
140 curl https://<app>/api/sources
140141 ```
141142
143 `GET /api/sources` is the directory: one entry per source that has ever
144 posted, newest activity first, each with its busiest kinds — at most 50
145 sources and 10 kinds per source, so it stays cheap no matter how much a
146 source invents. A source with more kinds than that carries `more_kinds`
147 rather than lying about its total. The same directory sits above the
148 timeline on `/`, each source and kind a link that narrows it.
149
142150 Writes are rationed per person, twenty in hand and two a second (per
143151 address for a stranger, since a person is their own bucket). Over it, a
144152 write is refused with `429` and a `retry-after` in whole seconds; the JSON
@@ -150,7 +158,7 @@ sleep precisely rather than a rounded second too long:
150158 ```
151159
152160 As an MCP server, for Claude Code or Claude Desktop — `list_events`,
153 `post_event`, `get_event`:
161 `post_event`, `list_sources`, `get_event`:
154162
155163 ```sh
156164 claude mcp add --transport http __TITLE__ https://<app>/mcp
src/actions.test.ts +62
@@ -6,11 +6,15 @@ import {
66 getEvent,
77 listApps,
88 listEvents,
9 listSources,
910 postEvent,
1011 RELAY,
1112 registerApp,
1213 revokeApp,
1314 rotateAppKey,
15 SOURCE_KINDS_MAX,
16 SOURCE_RE,
17 SOURCES_MAX,
1418 } from "./actions.ts";
1519 import { appSecret } from "./apps.ts";
1620 import { openDb } from "./db.ts";
@@ -182,6 +186,64 @@ test("limit clamps to 1..200, default 50", () => {
182186 expect(def.ok).toBe(true);
183187 });
184188
189 test("listSources groups by source: counts, last_at, newest activity first", () => {
190 postEvent(db, root, { ...good, source: "dir-a", kind: "a.one" });
191 postEvent(db, root, { ...good, source: "dir-a", kind: "a.two" });
192 postEvent(db, root, {
193 ...good,
194 source: "dir-b",
195 kind: "b.one",
196 occurred_at: new Date(Date.now() - 60_000).toISOString(),
197 });
198 const listed = listSources(db, anon);
199 expect(listed.ok).toBe(true);
200 if (!listed.ok) return;
201 const a = listed.value.find((s) => s.source === "dir-a");
202 const b = listed.value.find((s) => s.source === "dir-b");
203 expect(a?.events).toBe(2);
204 expect(b?.events).toBe(1);
205 const ia = listed.value.findIndex((s) => s.source === "dir-a");
206 const ib = listed.value.findIndex((s) => s.source === "dir-b");
207 expect(ia).toBeGreaterThanOrEqual(0);
208 expect(ia).toBeLessThan(ib);
209 });
210
211 test("listSources caps at SOURCES_MAX sources and SOURCE_KINDS_MAX kinds per source, saying how many more", () => {
212 for (let i = 0; i < SOURCES_MAX + 5; i++)
213 postEvent(db, root, { ...good, source: `cap-src-${i}`, kind: "cap.kind" });
214 const capped = listSources(db, anon);
215 expect(capped.ok).toBe(true);
216 if (capped.ok) expect(capped.value.length).toBeLessThanOrEqual(SOURCES_MAX);
217
218 for (let i = 0; i < SOURCE_KINDS_MAX + 3; i++)
219 postEvent(db, root, { ...good, source: "cap-kinds", kind: `k${i}.one` });
220 const withKinds = listSources(db, anon);
221 expect(withKinds.ok).toBe(true);
222 if (!withKinds.ok) return;
223 const found = withKinds.value.find((s) => s.source === "cap-kinds");
224 expect(found).toBeDefined();
225 expect(found?.kinds.length).toBe(SOURCE_KINDS_MAX);
226 expect(found?.more_kinds).toBe(3);
227 });
228
229 test("listSources is the same directory for anon and admin", () => {
230 const asAnon = listSources(db, anon);
231 const asAdmin = listSources(db, root);
232 expect(asAnon).toEqual(asAdmin);
233 });
234
235 test("every source in the directory is only ever the SOURCE_RE shape", () => {
236 const listed = listSources(db, anon);
237 expect(listed.ok).toBe(true);
238 if (listed.ok)
239 for (const s of listed.value) expect(SOURCE_RE.test(s.source)).toBe(true);
240 });
241
242 test("listSources is empty when there are no events", () => {
243 const empty = openDb(mkdtempSync(join(tmpdir(), "app-actions-empty-")));
244 expect(listSources(empty, anon)).toEqual({ ok: true, value: [] });
245 });
246
185247 test("getEvent finds an event by id, 404 when absent", () => {
186248 const posted = postEvent(db, shop, good);
187249 if (!posted.ok) throw new Error(posted.message);
src/actions.ts +85
@@ -270,6 +270,91 @@ export function listEvents(
270270 return ok(rows);
271271 }
272272
273 export const SOURCES_MAX = 50;
274 export const SOURCE_KINDS_MAX = 10;
275
276 export interface SourceKind {
277 readonly kind: string;
278 readonly events: number;
279 }
280
281 export interface SourceSummary {
282 readonly source: string;
283 readonly events: number;
284 readonly last_at: string;
285 readonly kinds: readonly SourceKind[];
286 /** Kinds beyond `SOURCE_KINDS_MAX` this source has posted, or 0. */
287 readonly more_kinds: number;
288 }
289
290 /**
291 * The directory: one row per source, newest activity first, each with its
292 * busiest kinds. Both caps are enforced in SQL — `LIMIT` on the source
293 * query and `ROW_NUMBER()` on the kind query — so a source that invents
294 * kinds without bound cannot make either query grow past them.
295 */
296 export function listSources(db: Database, _who: Who): Result<SourceSummary[]> {
297 const sources = db
298 .query<
299 { source: string; events: number; last_at: string },
300 [number]
301 >(
302 `SELECT source, COUNT(*) AS events, MAX(occurred_at) AS last_at
303 FROM event
304 GROUP BY source
305 ORDER BY last_at DESC
306 LIMIT ?1`,
307 )
308 .all(SOURCES_MAX);
309
310 if (sources.length === 0) return ok([]);
311
312 const placeholders = sources.map((_, i) => `?${i + 2}`).join(", ");
313 const kindRows = db
314 .query<
315 { source: string; kind: string; events: number; total: number },
316 (number | string)[]
317 >(
318 `WITH counted AS (
319 SELECT source, kind, COUNT(*) AS events
320 FROM event
321 WHERE source IN (${placeholders})
322 GROUP BY source, kind
323 ), ranked AS (
324 SELECT source, kind, events,
325 ROW_NUMBER() OVER (PARTITION BY source ORDER BY events DESC, kind ASC) AS rn,
326 COUNT(*) OVER (PARTITION BY source) AS total
327 FROM counted
328 )
329 SELECT source, kind, events, total FROM ranked WHERE rn <= ?1
330 ORDER BY source, events DESC, kind ASC`,
331 )
332 .all(SOURCE_KINDS_MAX, ...sources.map((s) => s.source));
333
334 const bySource = new Map<string, { kinds: SourceKind[]; total: number }>();
335 for (const row of kindRows) {
336 let entry = bySource.get(row.source);
337 if (entry === undefined) {
338 entry = { kinds: [], total: row.total };
339 bySource.set(row.source, entry);
340 }
341 entry.kinds.push({ kind: row.kind, events: row.events });
342 }
343
344 return ok(
345 sources.map((s) => {
346 const entry = bySource.get(s.source);
347 return {
348 source: s.source,
349 events: s.events,
350 last_at: s.last_at,
351 kinds: entry?.kinds ?? [],
352 more_kinds: entry === undefined ? 0 : Math.max(entry.total - SOURCE_KINDS_MAX, 0),
353 };
354 }),
355 );
356 }
357
273358 export function getEvent(
274359 db: Database,
275360 _who: Who,
src/db.ts +4
@@ -54,6 +54,10 @@ export const migrations: string[] = [
5454 generation INTEGER NOT NULL DEFAULT 1,
5555 revoked_at TEXT
5656 )`,
57 // Covering indexes for the source directory: each query is answered
58 // entirely from the index, never touching the event table itself.
59 `CREATE INDEX IF NOT EXISTS event_source_occurred ON event (source, occurred_at)`,
60 `CREATE INDEX IF NOT EXISTS event_source_kind ON event (source, kind)`,
5761 ];
5862
5963 export function openDb(dir = dataDir): Database {
src/mcp.ts +13 −1
@@ -7,7 +7,13 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { getEvent, listEvents, postEvent, type Result } from "./actions.ts";
10 import {
11 getEvent,
12 listEvents,
13 listSources,
14 postEvent,
15 type Result,
16 } from "./actions.ts";
1117 import { title } from "./env.ts";
1218 import { fields } from "./http.ts";
1319 import type { Who } from "./who.ts";
@@ -70,6 +76,12 @@ export const tools: readonly Tool[] = [
7076 },
7177 run: (db, who, args) => postEvent(db, who, args),
7278 },
79 {
80 name: "list_sources",
81 description: "Who posts here and what kinds, busiest activity first.",
82 inputSchema: { type: "object", properties: {} },
83 run: (db, who) => listSources(db, who),
84 },
7385 {
7486 name: "get_event",
7587 description: "One event by id.",
src/page.ts +42 −4
@@ -4,7 +4,7 @@
44 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
55 */
66
7 import type { Event } from "./actions.ts";
7 import type { Event, SourceSummary } from "./actions.ts";
88 import {
99 commit,
1010 description,
@@ -77,18 +77,19 @@ export function layout(body: string, who: Who, ssh: string | undefined): string
7777 export function home(input: {
7878 who: Who;
7979 events: readonly Event[];
80 sources: readonly SourceSummary[];
8081 source: string | null;
8182 kind: string | null;
8283 error?: string;
8384 ssh: string | undefined;
8485 }): string {
85 const { who, events, source, kind, error, ssh } = input;
86 const { who, events, sources, source, kind, error, ssh } = input;
8687 const filtered = source !== null || kind !== null;
8788 const body =
8889 events.length === 0 ? empty(filtered) : days(events, source, kind);
8990 return layout(
9091 `<section id="events" data-live="event.posted">
91 ${toolbar(source, kind, error)}
92 ${toolbar(sources, source, kind, error)}
9293 ${body}
9394 </section>`,
9495 who,
@@ -108,8 +109,44 @@ const filterHref = (source: string | null, kind: string | null): string => {
108109 const toggle = (current: string | null, value: string): string | null =>
109110 current === value ? null : value;
110111
111 /** The action's message when a filter is invalid, and the active filter as clearable chips. */
112 /** One source's chip and its busiest kinds, each a link that narrows the timeline. */
113 const sourceItem = (
114 s: SourceSummary,
115 source: string | null,
116 kind: string | null,
117 ): string => {
118 const active = source === s.source;
119 const kinds = s.kinds
120 .map(
121 (k) =>
122 `<a class="kind${active && kind === k.kind ? " kind-active" : ""}" href="${escape(filterHref(s.source, toggle(kind, k.kind)))}">${escape(k.kind)}</a>`,
123 )
124 .join(" ");
125 const more =
126 s.more_kinds > 0 ? `<span class="muted">+${s.more_kinds} more</span>` : "";
127 return `<li class="source-item">
128 <a class="badge${active ? " badge-active" : ""}" href="${escape(filterHref(toggle(source, s.source), kind))}">${escape(s.source)} <span class="muted">${s.events}</span></a>
129 ${kinds}${more}
130 </li>`;
131 };
132
133 /** Who is talking and about what, newest activity first. Nothing when there is nothing yet. */
134 const directory = (
135 sources: readonly SourceSummary[],
136 source: string | null,
137 kind: string | null,
138 ): string =>
139 sources.length === 0
140 ? ""
141 : `<ul class="sources">${sources.map((s) => sourceItem(s, source, kind)).join("")}</ul>`;
142
143 /**
144 * One toolbar: the action's message when a filter is invalid, the source
145 * directory, and the active filter as clearable chips — never two rows
146 * stacked for what is one control surface.
147 */
112148 const toolbar = (
149 sources: readonly SourceSummary[],
113150 source: string | null,
114151 kind: string | null,
115152 error: string | undefined,
@@ -117,6 +154,7 @@ const toolbar = (
117154 const parts: string[] = [];
118155 if (error !== undefined)
119156 parts.push(`<p class="notice" role="alert">${escape(error)}</p>`);
157 parts.push(directory(sources, source, kind));
120158 const chips: string[] = [];
121159 if (source !== null)
122160 chips.push(
src/routes.ts +8
@@ -8,6 +8,7 @@ import type { Database } from "bun:sqlite";
88 import {
99 getEvent,
1010 listEvents,
11 listSources,
1112 postEvent,
1213 type Failure,
1314 type Result,
@@ -72,10 +73,12 @@ export const routes: readonly Route[] = [
7273 source: source ?? undefined,
7374 kind: kind ?? undefined,
7475 });
76 const sources = listSources(c.db, c.who);
7577 return html(
7678 home({
7779 who: c.who,
7880 events: listed.ok ? listed.value : [],
81 sources: sources.ok ? sources.value : [],
7982 source,
8083 kind,
8184 error: listed.ok ? undefined : listed.message,
@@ -117,6 +120,11 @@ export const routes: readonly Route[] = [
117120 path: "/api/events/:id",
118121 handle: (c) => answer(getEvent(c.db, c.who, { id: Number(c.params.id) })),
119122 },
123 {
124 method: "GET",
125 path: "/api/sources",
126 handle: (c) => answer(listSources(c.db, c.who)),
127 },
120128
121129 // Live events, and the MCP server (the tools are the actions too).
122130 { method: "GET", path: "/events", handle: (c) => events(c.request) },
src/server.test.ts +95
@@ -2,6 +2,7 @@ import { afterAll, expect, test } from "bun:test";
22 import { cpSync, mkdtempSync, rmSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { openDb } from "./db.ts";
56 import { serve } from "./server.ts";
67 import { sign } from "./who.ts";
78
@@ -173,6 +174,49 @@ test("kind and source filters work over the API", async () => {
173174 );
174175 });
175176
177 test("the source directory: the API, the page and the MCP tool agree", async () => {
178 await api(
179 "/api/events",
180 { method: "POST", ...jsonBody({ ...event, source: "shop", kind: "order.shipped" }) },
181 root,
182 );
183 const viaApi = (await (await fetch(base + "/api/sources")).json()) as {
184 source: string;
185 events: number;
186 kinds: { kind: string; events: number }[];
187 }[];
188 const shop = viaApi.find((s) => s.source === "shop");
189 expect(shop).toBeDefined();
190 expect(shop?.kinds.some((k) => k.kind === "order.shipped")).toBe(true);
191
192 const page = await (await fetch(base + "/")).text();
193 expect(page).toContain('<a class="badge');
194 expect(page).toContain(">shop<");
195 expect(page).toContain("order.shipped");
196 expect(page).toContain(`href="/?source=shop"`);
197
198 const viaMcp = await rpc({
199 jsonrpc: "2.0",
200 id: 100,
201 method: "tools/call",
202 params: { name: "list_sources", arguments: {} },
203 });
204 const fromMcp = JSON.parse(viaMcp.result.content[0].text);
205 expect(fromMcp).toEqual(viaApi);
206 });
207
208 test("the directory sets ?source= carrying kind forward, and marks the active one", async () => {
209 await api(
210 "/api/events",
211 { method: "POST", ...jsonBody({ ...event, source: "billing", kind: "invoice.sent" }) },
212 root,
213 );
214 const filtered = await (await fetch(base + "/?kind=invoice.sent")).text();
215 expect(filtered).toContain(`href="/?source=billing&amp;kind=invoice.sent"`);
216 const active = await (await fetch(base + "/?source=billing")).text();
217 expect(active).toMatch(/badge badge-active" href="[^"]*">billing/);
218 });
219
176220 test("the MCP server lists the tools and calls them as the caller", async () => {
177221 const hello = await rpc({
178222 jsonrpc: "2.0",
@@ -185,6 +229,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
185229 expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
186230 "list_events",
187231 "post_event",
232 "list_sources",
188233 "get_event",
189234 ]);
190235 const call = (name: string, args: unknown, who = {}) =>
@@ -569,6 +614,56 @@ test("with no commit configured (a local run) no boot event is posted", async ()
569614 }
570615 });
571616
617 test("the source directory stays fast: 5,000 events across 20 sources, indexed and inside budget", async () => {
618 const dir = mkdtempSync(join(tmpdir(), "app-scale-"));
619 const seed = openDb(dir);
620 const insert = seed.query(
621 `INSERT INTO event (source, kind, subject, summary, link, occurred_at, dedupe)
622 VALUES (?1, ?2, ?3, ?4, NULL, ?5, NULL)`,
623 );
624 seed.transaction(() => {
625 for (let i = 0; i < 5000; i++) {
626 insert.run(
627 `scale-${i % 20}`,
628 `k${i % 7}.event`,
629 "Subject",
630 "Summary",
631 new Date(Date.now() - i * 1000).toISOString(),
632 );
633 }
634 })();
635
636 const plan = seed
637 .query<
638 { detail: string },
639 [number]
640 >(
641 `EXPLAIN QUERY PLAN
642 SELECT source, COUNT(*) AS events, MAX(occurred_at) AS last_at
643 FROM event GROUP BY source ORDER BY last_at DESC LIMIT ?1`,
644 )
645 .all(50)
646 .map((row) => row.detail)
647 .join(" ; ");
648 expect(plan).toMatch(/USING (COVERING )?INDEX/);
649 expect(plan).not.toContain("USE TEMP B-TREE FOR GROUP BY");
650
651 const scaled = serve({ port: 0, dataDir: dir, identityKey: KEY, commit: "" });
652 try {
653 const at = `http://${scaled.hostname}:${scaled.port}`;
654 await fetch(at + "/"); // warm the process up before timing it.
655 const start = performance.now();
656 const res = await fetch(at + "/");
657 const body = await res.text();
658 const elapsed = performance.now() - start;
659 expect(res.status).toBe(200);
660 expect(body).toContain("scale-0");
661 expect(elapsed).toBeLessThan(50);
662 } finally {
663 scaled.stop(true);
664 }
665 });
666
572667 test("a missing adoption-kit file fails the boot, not a request", () => {
573668 const dir = mkdtempSync(join(tmpdir(), "relay-boot-"));
574669 for (const name of ["src", "client", "static", "docs", "package.json"]) {
static/app.css +26
@@ -279,3 +279,29 @@ footer code {
279279 border-color: var(--accent);
280280 text-decoration: none;
281281 }
282
283 /* The directory: one wrapped list of sources, each with its kinds beside
284 it. Capped in height so it never pushes the timeline's first row off a
285 phone screen even with every source it can hold. */
286 .sources {
287 list-style: none;
288 margin: 0 0 var(--s4);
289 padding: 0;
290 display: flex;
291 flex-wrap: wrap;
292 gap: var(--s2) var(--s3);
293 max-height: 9rem;
294 overflow-y: auto;
295 }
296 .source-item {
297 display: flex;
298 flex-wrap: wrap;
299 align-items: center;
300 gap: var(--s1);
301 padding: var(--s1) 0;
302 }
303 .sources .badge,
304 .sources .kind {
305 min-height: var(--tap);
306 padding: 0 var(--s3);
307 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs