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
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/47-what-is-flowing-the-sources-and-kinds-th → main 1 commit fork
9 files
changed +344
−6
README.md +9 −1
| @@ -137,8 +137,16 @@ curl https://<app>/api/events?source=shop&kind=order&limit=20 | ||
| 137 | 137 | curl https://<app>/api/events/1 |
| 138 | 138 | curl -X POST https://<app>/api/events -H 'content-type: application/json' \ |
| 139 | 139 | -d '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in.","source":"shop"}' |
| 140 | curl https://<app>/api/sources | |
| 140 | 141 | |
| 141 | 142 | |
| 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 | ||
| 142 | 150 | Writes are rationed per person, twenty in hand and two a second (per |
| 143 | 151 | address for a stranger, since a person is their own bucket). Over it, a |
| 144 | 152 | 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: | ||
| 150 | 158 | |
| 151 | 159 | |
| 152 | 160 | As an MCP server, for Claude Code or Claude Desktop — `list_events`, |
| 153 | `post_event`, `get_event`: | |
| 161 | `post_event`, `list_sources`, `get_event`: | |
| 154 | 162 | |
| 155 | 163 | sh |
| 156 | 164 | claude mcp add --transport http __TITLE__ https://<app>/mcp |
src/actions.test.ts +62
| @@ -6,11 +6,15 @@ import { | ||
| 6 | 6 | getEvent, |
| 7 | 7 | listApps, |
| 8 | 8 | listEvents, |
| 9 | listSources, | |
| 9 | 10 | postEvent, |
| 10 | 11 | RELAY, |
| 11 | 12 | registerApp, |
| 12 | 13 | revokeApp, |
| 13 | 14 | rotateAppKey, |
| 15 | SOURCE_KINDS_MAX, | |
| 16 | SOURCE_RE, | |
| 17 | SOURCES_MAX, | |
| 14 | 18 | } from "./actions.ts"; |
| 15 | 19 | import { appSecret } from "./apps.ts"; |
| 16 | 20 | import { openDb } from "./db.ts"; |
| @@ -182,6 +186,64 @@ test("limit clamps to 1..200, default 50", () => { | ||
| 182 | 186 | expect(def.ok).toBe(true); |
| 183 | 187 | }); |
| 184 | 188 | |
| 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 | ||
| 185 | 247 | test("getEvent finds an event by id, 404 when absent", () => { |
| 186 | 248 | const posted = postEvent(db, shop, good); |
| 187 | 249 | if (!posted.ok) throw new Error(posted.message); |
src/actions.ts +85
| @@ -270,6 +270,91 @@ export function listEvents( | ||
| 270 | 270 | return ok(rows); |
| 271 | 271 | } |
| 272 | 272 | |
| 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 | ||
| 273 | 358 | export function getEvent( |
| 274 | 359 | db: Database, |
| 275 | 360 | _who: Who, |
src/db.ts +4
| @@ -54,6 +54,10 @@ export const migrations: string[] = [ | ||
| 54 | 54 | generation INTEGER NOT NULL DEFAULT 1, |
| 55 | 55 | revoked_at TEXT |
| 56 | 56 | )`, |
| 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)`, | |
| 57 | 61 | ]; |
| 58 | 62 | |
| 59 | 63 | export function openDb(dir = dataDir): Database { |
src/mcp.ts +13 −1
| @@ -7,7 +7,13 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 17 | import { title } from "./env.ts"; |
| 12 | 18 | import { fields } from "./http.ts"; |
| 13 | 19 | import type { Who } from "./who.ts"; |
| @@ -70,6 +76,12 @@ export const tools: readonly Tool[] = [ | ||
| 70 | 76 | }, |
| 71 | 77 | run: (db, who, args) => postEvent(db, who, args), |
| 72 | 78 | }, |
| 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 | }, | |
| 73 | 85 | { |
| 74 | 86 | name: "get_event", |
| 75 | 87 | description: "One event by id.", |
src/page.ts +42 −4
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Event } from "./actions.ts"; | |
| 7 | import type { Event, SourceSummary } from "./actions.ts"; | |
| 8 | 8 | import { |
| 9 | 9 | commit, |
| 10 | 10 | description, |
| @@ -77,18 +77,19 @@ export function layout(body: string, who: Who, ssh: string | undefined): string | ||
| 77 | 77 | export function home(input: { |
| 78 | 78 | who: Who; |
| 79 | 79 | events: readonly Event[]; |
| 80 | sources: readonly SourceSummary[]; | |
| 80 | 81 | source: string | null; |
| 81 | 82 | kind: string | null; |
| 82 | 83 | error?: string; |
| 83 | 84 | ssh: string | undefined; |
| 84 | 85 | }): string { |
| 85 | const { who, events, source, kind, error, ssh } = input; | |
| 86 | const { who, events, sources, source, kind, error, ssh } = input; | |
| 86 | 87 | const filtered = source !== null || kind !== null; |
| 87 | 88 | const body = |
| 88 | 89 | events.length === 0 ? empty(filtered) : days(events, source, kind); |
| 89 | 90 | return layout( |
| 90 | 91 | `<section id="events" data-live="event.posted"> |
| 91 | ${toolbar(source, kind, error)} | |
| 92 | ${toolbar(sources, source, kind, error)} | |
| 92 | 93 | ${body} |
| 93 | 94 | </section>`, |
| 94 | 95 | who, |
| @@ -108,8 +109,44 @@ const filterHref = (source: string | null, kind: string | null): string => { | ||
| 108 | 109 | const toggle = (current: string | null, value: string): string | null => |
| 109 | 110 | current === value ? null : value; |
| 110 | 111 | |
| 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 | */ | |
| 112 | 148 | const toolbar = ( |
| 149 | sources: readonly SourceSummary[], | |
| 113 | 150 | source: string | null, |
| 114 | 151 | kind: string | null, |
| 115 | 152 | error: string | undefined, |
| @@ -117,6 +154,7 @@ const toolbar = ( | ||
| 117 | 154 | const parts: string[] = []; |
| 118 | 155 | if (error !== undefined) |
| 119 | 156 | parts.push(`<p class="notice" role="alert">${escape(error)}</p>`); |
| 157 | parts.push(directory(sources, source, kind)); | |
| 120 | 158 | const chips: string[] = []; |
| 121 | 159 | if (source !== null) |
| 122 | 160 | chips.push( |
src/routes.ts +8
| @@ -8,6 +8,7 @@ import type { Database } from "bun:sqlite"; | ||
| 8 | 8 | import { |
| 9 | 9 | getEvent, |
| 10 | 10 | listEvents, |
| 11 | listSources, | |
| 11 | 12 | postEvent, |
| 12 | 13 | type Failure, |
| 13 | 14 | type Result, |
| @@ -72,10 +73,12 @@ export const routes: readonly Route[] = [ | ||
| 72 | 73 | source: source ?? undefined, |
| 73 | 74 | kind: kind ?? undefined, |
| 74 | 75 | }); |
| 76 | const sources = listSources(c.db, c.who); | |
| 75 | 77 | return html( |
| 76 | 78 | home({ |
| 77 | 79 | who: c.who, |
| 78 | 80 | events: listed.ok ? listed.value : [], |
| 81 | sources: sources.ok ? sources.value : [], | |
| 79 | 82 | source, |
| 80 | 83 | kind, |
| 81 | 84 | error: listed.ok ? undefined : listed.message, |
| @@ -117,6 +120,11 @@ export const routes: readonly Route[] = [ | ||
| 117 | 120 | path: "/api/events/:id", |
| 118 | 121 | handle: (c) => answer(getEvent(c.db, c.who, { id: Number(c.params.id) })), |
| 119 | 122 | }, |
| 123 | { | |
| 124 | method: "GET", | |
| 125 | path: "/api/sources", | |
| 126 | handle: (c) => answer(listSources(c.db, c.who)), | |
| 127 | }, | |
| 120 | 128 | |
| 121 | 129 | // Live events, and the MCP server (the tools are the actions too). |
| 122 | 130 | { 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"; | ||
| 2 | 2 | import { cpSync, mkdtempSync, rmSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { openDb } from "./db.ts"; | |
| 5 | 6 | import { serve } from "./server.ts"; |
| 6 | 7 | import { sign } from "./who.ts"; |
| 7 | 8 | |
| @@ -173,6 +174,49 @@ test("kind and source filters work over the API", async () => { | ||
| 173 | 174 | ); |
| 174 | 175 | }); |
| 175 | 176 | |
| 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&kind=invoice.sent"`); | |
| 216 | const active = await (await fetch(base + "/?source=billing")).text(); | |
| 217 | expect(active).toMatch(/badge badge-active" href="[^"]*">billing/); | |
| 218 | }); | |
| 219 | ||
| 176 | 220 | test("the MCP server lists the tools and calls them as the caller", async () => { |
| 177 | 221 | const hello = await rpc({ |
| 178 | 222 | jsonrpc: "2.0", |
| @@ -185,6 +229,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 185 | 229 | expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([ |
| 186 | 230 | "list_events", |
| 187 | 231 | "post_event", |
| 232 | "list_sources", | |
| 188 | 233 | "get_event", |
| 189 | 234 | ]); |
| 190 | 235 | 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 () | ||
| 569 | 614 | } |
| 570 | 615 | }); |
| 571 | 616 | |
| 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 | ||
| 572 | 667 | test("a missing adoption-kit file fails the boot, not a request", () => { |
| 573 | 668 | const dir = mkdtempSync(join(tmpdir(), "relay-boot-")); |
| 574 | 669 | for (const name of ["src", "client", "static", "docs", "package.json"]) { |
static/app.css +26
| @@ -279,3 +279,29 @@ footer code { | ||
| 279 | 279 | border-color: var(); |
| 280 | 280 | text-decoration: none; |
| 281 | 281 | } |
| 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(); | |
| 289 | padding: 0; | |
| 290 | display: flex; | |
| 291 | flex-wrap: wrap; | |
| 292 | gap: var() var(); | |
| 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(); | |
| 301 | padding: var() 0; | |
| 302 | } | |
| 303 | .sources .badge, | |
| 304 | .sources .kind { | |
| 305 | min-height: var(); | |
| 306 | padding: 0 var(); | |
| 307 | } |