Files
yaak-mountain-loop/apps/yaak-proxy/lib/flowWindow.ts
T
Gregory SchierandClaude Opus 5.5 1a994f7532 Keep the proxy app fast with many flows
The app held every flow in memory and redid all of its work for each new
one: the sidebar mounted every domain and path node (its tree scrolled
inside a container that never bounded it), every tree row looked up its
atoms by scanning every atom the family had made, and the layout, detail
pane and list rows re-rendered per flow. At 3,000 flows each new one cost
about 90 ms of main thread, more than the traffic left time for.

The app now holds a window of the newest 2,000 flows, applies new ones in
batches, and loads older ones in pages from the database on scrolling to
the top, through a new list_flows RPC that pages in SQL. The database
keeps every flow.

Also adds clear_flows: an RPC, POST /flows/clear on the control API, and
a Clear button in the header that Cmd+K opens, confirming first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:52:00 -07:00

101 lines
3.8 KiB
TypeScript

import type { Flow, ModelPayload } from "@yaakapp-internal/proxy-lib";
/** Flows the window keeps while it follows new traffic */
export const WINDOW_SIZE = 2000;
/**
* Flows past `WINDOW_SIZE` before the oldest are dropped, so they go a few hundred at a time
* rather than one per new flow, which would shift every row of the list each time.
*/
export const WINDOW_SLACK = 500;
/** Older flows loaded per page on scrolling to the top */
export const PAGE_SIZE = 500;
/**
* The flows the app holds in memory: the newest in the database, oldest first, ordered by start
* and then id as `list_flows` orders them. The database keeps every flow; older ones load back
* in pages.
*/
export interface FlowWindow {
flows: Flow[];
/** The database has flows older than the first one here */
hasOlder: boolean;
/** Flows in the database */
total: number;
}
export const emptyWindow: FlowWindow = { flows: [], hasOlder: false, total: 0 };
type FlowKey = Pick<Flow, "startedAt" | "id">;
export function compareFlows(a: FlowKey, b: FlowKey): number {
if (a.startedAt !== b.startedAt) return a.startedAt < b.startedAt ? -1 : 1;
if (a.id !== b.id) return a.id < b.id ? -1 : 1;
return 0;
}
/** The window from the newest page of `list_flows`, which comes newest first */
export function windowFromPage(page: Flow[], limit: number, total: number): FlowWindow {
return { flows: [...page].reverse(), hasOlder: page.length >= limit, total };
}
/**
* Adds new flows where they belong and replaces changed ones. New traffic almost always lands at
* the end, so this is an append. A flow that started before the oldest one held, while older ones
* are still in the database, is left for paging to load, so the window never has a gap.
*/
export function applyChanges(win: FlowWindow, changes: ModelPayload[]): FlowWindow {
if (changes.length === 0) return win;
const flows = win.flows.slice();
let total = win.total;
for (const { model, change } of changes) {
const i = insertionIndex(flows, model);
const existing = flows[i]?.id === model.id;
if (change.type === "delete") {
if (existing) flows.splice(i, 1);
total = Math.max(0, total - 1);
} else if (existing) {
flows[i] = model;
} else {
if (change.created) total++;
const first = flows[0];
if (win.hasOlder && first != null && compareFlows(model, first) < 0) continue;
flows.splice(i, 0, model);
}
}
return { ...win, flows, total };
}
/**
* Drops the oldest flows once there are `slack` more than `size`, back down to `size`. Always an
* even number of them, so the list's alternating rows keep their shade.
*/
export function trimWindow(win: FlowWindow, size = WINDOW_SIZE, slack = WINDOW_SLACK): FlowWindow {
if (win.flows.length <= size + slack) return win;
let drop = win.flows.length - size;
drop += drop % 2;
return { ...win, flows: win.flows.slice(drop), hasOlder: true };
}
/** Adds a page of older flows from `list_flows`, newest first, before the oldest one held */
export function prependOlder(win: FlowWindow, page: Flow[], limit: number): FlowWindow {
const first = win.flows[0];
const older = page.filter((f) => first == null || compareFlows(f, first) < 0).reverse();
return { ...win, flows: [...older, ...win.flows], hasOlder: page.length >= limit };
}
/** Where `flow` is, or would go, in `flows` */
function insertionIndex(flows: FlowKey[], flow: FlowKey): number {
// New traffic goes at the end; skip the search for it.
const last = flows[flows.length - 1];
if (last == null || compareFlows(last, flow) < 0) return flows.length;
let lo = 0;
let hi = flows.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
const item = flows[mid];
if (item != null && compareFlows(item, flow) < 0) lo = mid + 1;
else hi = mid;
}
return lo;
}