Repository navigation
refactor(events): derive event groups on read in the grouped-event-buffer - #3790
Conversation
The buffer still carried three subsystems with no callers: the Pixi track-layout machinery (assignTrackIndices, mergeHeads, getGroupMeta and the trackIndex/pixiType/pixiStatus/startMs/endMs fields it maintained), the promise-per-row streaming API (getRows, getLatestGroup and their resolver maps), and the group-registration listeners (onLatestGroup, onLiveGroup). Also removes five unused count accessors, getLatestEvent, and resetLive, which duplicated what reset already clears. The app imports 7 functions from this module; 15 of the 28 exports were reachable only from the test file. Tests whose subject was the deleted API are gone; the rest now read groups via getGroupArray. No behavior change.
Replaces the buffer's two ingestion pipelines and mutable group store with a single flat event array plus per-group records that materialize EventGroup objects on read. Before, `processEvent` (bidirectional fetch) and `appendLiveEvent` (live poll) each kept their own group store, pending-follower map, and dedup set, and a reconcile step merged them; three read paths then had to de-duplicate the two stores. "Add an event to a group" was written six times, and each copy had to remember to publish a fresh group reference so reference-tracking Svelte views re-derive — the timeline row pool keys on group identity, and two separate copies had already been found mutating a rendered group in place, leaving completed activities rendering as in progress. Now `ingest` handles both producers: it writes the converted event into events[eventId - 1] and appends the slot to the GroupRecord for its head event. Groups are built by `materialize` and memoized on the record's version, so identity is a pure function of content — unchanged groups read back as the same object, changed groups always as a new one, and there is no published group to forget to replace. Each materialized group owns its eventList, so a superseded reference stays a consistent snapshot rather than a half-updated one. Consequences: - pendingFollowers/livePendingFollowers, liveGroups, liveSeenIds, absorbLiveGroupIntoPool, the write-once guards and the eventSlots release dance are all gone: an out-of-order follower just creates its record early, and one `events[slot]` check covers every dedup case. - Pending activity/nexus metadata is applied at build time rather than written into the group, so a terminal event and a stale pending entry resolve the same way in either arrival order. clearResolvedPendingState and enrichGroup's clone dance go away. - Six manual cache-invalidation call sites collapse to one revision counter; getEventArray and getWorkflowTaskFailedEvent become single passes with no sort, dedup, or dual-store scan. - cloneEventGroup is no longer needed. Module state drops from ~20 variables to 6. Measured on a synthetic 200k-event / 80k-group history: ingest 1592ms -> 703ms, getEventArray 10.4ms -> 1.7ms, per-page group rebuild 1.2ms -> 3.5ms (work moves from ingest to read), net ~23% less total main-thread work.
Both workflow layouts hand-assembled the same subscribe / throttle / re-read effect to observe the buffer — 20 duplicated lines each — and the producer had to remember to announce its own writes via three separate bufferVersion.update() calls. A missed re-read or a missed announcement silently leaves the view showing stale groups. The buffer now reports its own writes through onChange, and a new grouped-event-buffer.svelte.ts wraps that in the reactive read surface consumers actually want: const groups = $derived(eventBuffer.groupsWithoutWorkflowTasks); const events = $derived(eventBuffer.events); Updates coalesce to one per animation frame, as the layouts' own rAF did; reset propagates immediately so navigating between runs cannot leave the previous run's groups on screen for a frame. enrichGroups moves to the writer. Both layouts were calling it on every buffer change once the fetch completed, racing the run layout's own call; workflow-run-layout already owns $workflowRun, so it now pushes pending metadata when the run refreshes and readers never touch it. The buffer stays a plain, Svelte-free data structure, unit-testable without a component runtime. bufferVersion survives as a store mirror for the pre-runes derived stores in $lib/stores/events. live-poll's onNewEvents hook is now optional — the buffer notifies on its own.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Three fixes from review of the buffer refactor. Centralizing enrichGroups in the writer dropped its dependency on the buffer: both layouts previously re-ran it on every bufferVersion bump. A run refresh can list a pendingActivity whose ActivityTaskScheduled the live poll ingests moments later, and enrichGroups skips records whose head event has not arrived, so that group rendered non-pending until the next refresh — up to 10s. The effect now tracks eventBuffer.version too; the function is idempotent and no-ops when nothing changed, so it settles. Coalescing moves from requestAnimationFrame to a microtask. rAF never fires in a hidden tab, and unlike before the refactor the bufferVersion store bump was behind that gate too, so a run opened in a background tab would leave $fullEventHistory and the SDK badge empty until focused. Fetch pages and poll batches are ingested in synchronous loops, so a microtask collapses each batch into one update just as rAF did. The live-completion spec held its response for a fixed 1000ms before asserting the group had not yet completed. The live poll starts before the initial fetch, so that raced hydration under load. It now holds until the test has observed the in-progress row and releases explicitly — also 0.8s faster per run. Verified it still fails when group updates mutate a published group in place. Also drops the unfiltered `groups` accessor, which had no consumer, and corrects the heap smoke test's title to match its threshold.
$fullEventHistory = ... compiles to store_set with no store_get, and this component only ever writes the store, so there was no dependency to untrack. The sibling effects keep theirs — those call helpers that read several stores.
The timeline renders a fixed pool of ~54 rows, but every consumer took a fully materialized EventGroup[], so opening a view built every group in the history — 561ms for 80k groups. Nothing upstream of a rendered row needs a group. The type filter reads `category`; sort-timeline-groups reads `isPending` and `initialEvent.id`; get-failed-or-pending reads `classification` and `initialEvent`; build-time-segments reads the head and last event times. All of those are derivable from the buffer's GroupRecord, so GroupRecord now exposes them as getters and `getGroupSummaries()` returns the records themselves — ordered, cached, and allocation-free beyond the array. Views materialize per rendered row via `materializeGroup`. Cold read at 200k events / 80k groups: 8.3ms for summaries plus 0.4ms for 54 rows, against 561ms to materialize everything. Supporting changes: - `isGroupHeadEvent` becomes the single source of truth for the group dispatch, so the buffer can ask whether a record forms a group without building one. createEventGroup's per-type branches only ever differed in their type parameter, so they collapse into it. - The four pure utilities take a structural type instead of EventGroup, so they serve summaries and groups alike and the compact view is unaffected. - EventGroup gains an `eventCount` getter, which makes it satisfy GroupSummary — graph-widget builds its own groups via groupEvents and passes them to the same component. `materializeGroup` is idempotent so that path needs no branching. - The row pool's reuse check now compares the summary's version as well as its identity. A summary's identity is stable for the whole run, so without that a group which gained an event would keep its slot object and the row would never re-read it — caught by the live-completion integration test. Group summaries and materialized groups must agree field for field, or a view filters on one and renders the other; tests assert that across every group type, complete and partially loaded, and both mutations I tried are caught.
The compact history view paginates 100 rows but took a fully materialized EventGroup[], so opening it built every group in the history. It now reads group summaries. Grouping, the category filter, pending-order and the status filter all run on summaries, and the table materializes each row as it renders — the rows snippet already receives only the paginated page. Supporting changes: - GroupSummary carries pendingActivity/pendingNexusOperation so the pending-row lookups match without materializing, and `isGroupSummary` lets a view tell a summary from a group or a plain event. - The compact view filters by status with getFailedOrPendingGroups rather than getFailedOrPendingEvents; the latter routes through isEventGroup, whose eventList check a summary cannot satisfy. The predicates are otherwise identical. - workflow-history-layout reads the materialized array only for the feed view, so the compact view never touches it. The feed view keeps full groups. Its gutter graph does cross-group layout that needs eventList, level, and reverse event->group lookups (history-graph, positioning), which is a separate piece of work. Adds an integration test for the compact view, which previously only asserted its tab button was visible. It renders rows, checks the group-derived cells, and switches back to feed; verified to fail when the rows are handed summaries without materializing.
…ey hold TimelineGraph's `groups` prop, and the locals feeding it, hold GroupSummary rather than EventGroup. Renames them so the type is legible at the call site: the prop becomes `groupSummaries`, `filteredGroups` becomes `filteredSummaries`, a row-pool slot holds a `summary`, and Timeline's `getEventGroups`/`eventGroups` become `getGroupSummaries`/ `groupSummaries`. EventSummaryTable's `groups` prop keeps its name — it genuinely takes either, summaries from the compact view and full groups from the feed view — as does history-graph, which only ever takes groups.
"Summary" reads as a condensed rendering of something — prose or a digest — but this type holds no contents at all, just the attributes a view needs to decide whether and where to draw a group. It is also not derived from an EventGroup: both come from the same record, and this is the one that lives for the whole run while the EventGroup is rebuilt on demand. LazyGroup names the deferral instead, and pairs with the verb that already exists: materializeGroup(lazy) forces it into an EventGroup. It also avoids implying the type is an EventGroup with missing fields, which would invite a cast rather than a call. EventGroup keeps its name — 142 references across 33 files of published API, and it is the domain's noun.
…sted Agreement between a LazyGroup and its materialized EventGroup was enforced only by tests comparing two independent implementations of the same rules. Three changes, so the tests police less: - isPending and category had their rules written twice, once over eventList and once over the record's slots. Both now call one shared function, so those fields cannot diverge. The rules themselves gain direct tests, since the agreement check can no longer catch a wrong rule — both sides would move together. - The agreement test now derives its field list from SHARED_WITH_EVENT_GROUP, whose `satisfies Record<keyof LazyGroup, boolean>` makes TypeScript reject a new LazyGroup field until it is classified. That table lives in the module rather than the test because src/lib/services/**/*.test.ts is excluded from `pnpm check`, which made the first version of this guard inert. - Agreement is checked at every prefix of each group's events rather than only complete and head-only, covering the partially loaded states the fetch actually produces.
Cleanup pass over the branch, no behavior change. Reuse: - One isFailedGroup rule, wrapped by isFailedEventGroup for the mixed-array filter, instead of the same three clauses written twice in one file. - materializeGroup and isLazyGroup use isEventGroup, the existing type guard for exactly that distinction; the `as unknown as` cast goes away. - getWorkflowTaskFailedEvent uses isFailedTaskEvent / isTimedOutTaskEvent rather than re-spelling their eventType comparisons. - isGroupHeadEvent covers WorkflowTask heads, so the buffer no longer bolts that case on at its one call site. Efficiency — id, category and head-eligibility are pure functions of a head event that never changes, but were getters on the hottest paths there are: the category filter reads `category` twice per group per pass and the row pool reads `id` for every group, both re-running on every batch. They are now assigned once, when the head event lands. - orderGroupsByPending and sortGroupsDuringLoading are stable partitions rather than toSorted. The key is a boolean over an already-ordered array, so this reads isPending once per group instead of ~n log n times. - getWorkflowTaskFailedEvent gets a revision cache like the other reads; it was rescanning every event on every version bump. - enrichGroups returns immediately when nothing is pending and nothing was previously enriched, instead of walking every record. It is re-driven on each batch so late-arriving heads pick up metadata, which made that walk the common case. Simplification: - createGroupFor's generic and the StartingEvents map were vestigial once the dispatch collapsed; dropping them removes nine type imports. - SHARED_WITH_EVENT_GROUP moves to test-helpers, which `pnpm check` still covers, so a test fixture is no longer exported from the module.
Audit of every comment the branch touches. Stale — mostly comments my own later commits invalidated: - GroupRecord's doc still said "every getter is a lookup into events" and "each getter must agree" after id and category became stored fields. - Six comments still said "summary"/"summaries" after the LazyGroup rename. - The live-completion spec's header still described copy-on-write handing the row a fresh reference; groups are now rebuilt on read and memoized per content version. - The boolean-return regression note cited bufferVersion.update(), which the reactive facade removed. - setFailedEvent's doc told callers to wire it from onFirstDescPage. No caller does, and none has since before this branch — it now says so. - The test file's section numbering jumped 13 to 17 after the prune. Trimmed, where the comment was longer than the code it explained: the facade's header, both partition helpers, the three structural-type notes, the stored-field rationale, and the enrich effect. Kept: the module header, the version field, the row pool's version comparison, and the microtask-not-rAF rationale — each records a decision whose reversal caused a bug.
Six of the casts the branch added were standing in for facts the compiler could have known. - materializeGroup discriminated with `isEventGroup` then cast to GroupRecord. `lazy instanceof GroupRecord` narrows for real, so the cast goes; isLazyGroup becomes the same one-line check. - initialEvent and lastEvent were getters casting away the nullability of the events array. Both are now assigned as members arrive — non-null from the moment the head lands, which is also when the record starts being handed out. That also drops a lookup and a scan per read. - EventSummaryTable's `groups` prop was `EventGroups | LazyGroup[]`, forcing `groups as EventGroups` at both use sites. The compact view never reads it — its items are groups themselves — so the layout passes none and the prop narrows back to EventGroups. The empty-Map branch and the materializing findGroup wrapper go with it. Remaining casts are load-bearing: `event as CommonHistoryEvent` bridges two event models and predates this branch, `materializeEventGroup(...) as EventGroup` drops a null that only unexposed records can produce, and the items union needs the component split to disappear.
The helper wrapped one function call and branched on `compact`, but its two callers weren't symmetric: paginatedHistory only runs under showGraph, which is false when compact, so it was routed through a compact check that can never be true — and had to widen its parameter to the union to get there. The branch now appears once, in a $derived, which also computes the filter once per change instead of once per call — it was running twice per render. paginatedHistory slices that and stops taking a parameter, so it no longer shadows the `items` prop.
The doc stated the mechanism without its premise, so the structure reads as arbitrary. Records that Temporal event IDs are a gapless sequence from 1, that the same arithmetic is what resolves a follower to its head in one array read, and that sparse IDs would cost memory rather than correctness.
"Records, Not Parking" only parsed if you already knew what parking was — and this branch deleted it, so most readers never will. The heading now states the property: arrival order doesn't matter. The historical subsection explains the old design forwards, from the problem it solved, rather than assuming the reader recognises the machinery by name.
| latestEventRef = raw; | ||
| } | ||
| grow(slot); | ||
| if (events[slot]) return false; |
There was a problem hiding this comment.
Is there a potential issue r.e. total billable events if we return before calling toWorkflowEvent here?
There was a problem hiding this comment.
if an event is in events[slot] it means it was ingested already which accounts for the total billable events.
There was a problem hiding this comment.
Got it. Might be worth adding in test case for this so we know to keep it before toWorkflowEvent.
Two bugs from review, both the same shape: something cached against a lazy
group's identity, which is stable as the group gains events.
A record kept the run's pending entry after the completion landed, because
only a head arrival or a setPendingMetadata call refreshed it. The lazy
isPending then said true while the materialized group said false — and
sorting and filtering read the lazy one, so a finished activity was hoisted
to the top as pending. A closed run stops refreshing, so nothing corrected
it. The record now tracks whether a terminal event has arrived and discounts
the run's entry itself, which also makes it the single source of truth:
materialize copies rather than re-deciding.
The compact table keyed its rows on group id alone, so a group that gained
an event reused the keyed block, the {@const} never re-ran, and the row kept
rendering its stale materialized group until the view remounted. The key now
includes the version.
Both are covered: unit tests for the lazy/materialized agreement across the
completion, and an integration test asserting a compact row picks up a live
completion. Both verified to fail without their fix.
The "the run can outlive the completion" rationale was written three times — on the terminal flags, at the gate, and on the materialize-side copy. It now lives at the gate, which is the only writer of the record's pending fields, and the other two say what they do rather than why. Also shortens the key comment, the regression note on the agreement test, and the compact spec header.
Takes #3807's marker dedup fix into the rewritten buffer: the shared processedWorkflowTaskIds set is now passed for every event rather than only the ascending cursor, and toWorkflowEvent no longer takes a direction. Conflicts were the rewrite against main's version of the same file, resolved in favour of the rewrite with #3807's semantics reapplied by hand: - grouped-event-buffer.ts — ours, plus the unconditional dedup set. ingestHistoryEvent's isAscending parameter is now unused; removing it touches ~180 call sites, so it is left for a follow-up commit. - grouped-event-buffer.test.ts — ours, plus the four marker tests ported from processEvent to ingestHistoryEvent. Verified they still fail 2/4 against the old gate. - synthetic-events.ts — auto-merge produced two makeLocalActivityMarker declarations; folded into one whose second argument defaults to eventId - 1, preserving both call styles.
…oryEvent The workflow-task dedup set was the flag's last consumer; #3807 made that set unconditional, and the rewritten buffer has no track assignment to distinguish cursors for. Every event is now ingested the same way regardless of which producer delivered it. 190 call sites, all but two in tests. fetchBidirectional still reports the direction on onRawPage — the layout just no longer forwards it. The marker tests are reworded around arrival order rather than cursor, since the distinction no longer exists at this boundary.
| if (isLazyGroup(event)) { | ||
| // Version, because a lazy group keeps its identity as it gains events — | ||
| // key on id alone and the row below renders a stale materialized group. | ||
| return `group-${event.id}-${event.version}`; |
There was a problem hiding this comment.
NIT: If the version changes (e.g. the group gains an event) does this mean it's going to remount and potentially lose the "expanded" state for the row?
There was a problem hiding this comment.
yes! I reverted that change and moved to use a materializeRowReactive function instead of changing the key.
| latestEventRef = raw; | ||
| } | ||
| grow(slot); | ||
| if (events[slot]) return false; |
There was a problem hiding this comment.
Got it. Might be worth adding in test case for this so we know to keep it before toWorkflowEvent.
Both cursors can deliver the same event, and conversion is what charges billable actions and registers a marker's workflow task. The write-once guard therefore has to reject a redelivery before toWorkflowEvent runs. Spies on toEvent rather than asserting the total: the total sums the stored events, one per slot, so a converted-then-discarded duplicate cannot change it. Verified by moving the conversion above the guard — the totals stay correct and only the call counts catch it.
Keying the row on the group's version fixed stale content by forcing a
remount, but EventSummaryRow holds `expanded` in component-local state — so
on a running workflow, expanding an activity and then receiving any event
for that group snapped the row shut.
Key on the group id again and make the row's materialization itself depend
on the buffer's version, so rows update in place instead of remounting.
Group records are plain objects, so nothing about them is reactive; reading
eventBuffer.version inside the {@const}'s own computation is what
subscribes it. A sibling {@const} reading the version does not work —
each compiles to its own derived with its own dependencies.
Both properties are now pinned by tests that fail independently: dropping
the version read leaves the row stale, and putting the version back in the
key drops the expansion.
Auto-generated version bump from 2.53.3 to 2.54.0 Bump type: minor Changes included: - [`7a5684a5`](7a5684a) Check if api has been upgraded for minimum minor bump (#3823) - [`af39e483`](af39e48) refactor(events): derive event groups on read in the grouped-event-buffer (#3790) - [`3ce42ebb`](3ce42eb) Add the developer Workflow Catalog (#3785) - [`a1234e36`](a1234e3) chore: split Catalog into a focused Temporal UI plugin (#3835) - [`060eacf7`](060eacf) Get total slots based on available plus used (#3833) - [`283d3cfc`](283d3cf) fix(timezone): use regular font weight for the timezone switcher (#3834) - [`88d61845`](88d6184) Manual on-call dependency review with constrained Claude remediation (#3816) - [`f87d239e`](f87d239) ci: validate the weekly review candidate the way the PR checks do (#3841) - [`dd80eca0`](dd80eca) feat(ci): apply the dependency remediation without a model (#3845) - [`403e5b97`](403e5b9) refactor(icons)!: replace holocene's icon registry with Io icon components (#3822) - [`d6823cc4`](d6823cc) fix(ci): correct the provenance claim in the remediation PR body (#3847) - [`f1b05e99`](f1b05e9) feat(ci): post the weekly review only when it needs a person (#3848) - [`b993c04b`](b993c04) ci: run the on-call review test only for an on-call change (#3850) - [`4307d20f`](4307d20) fix(copyable): pin the copy icon to 16px (#3855) - [`62e3014f`](62e3014) Update SAA heartbeat info (#3856) - [`3a797592`](3a79759) Headers spacing alignment and Small Standalone Activity UI fixes (#3854) - [`41687b1f`](41687b1) Add total Worker count (#3641) - [`68a3bc2f`](68a3bc2) fix(catalog): keep isolated test fixtures out of the repo root (#3859) - [`9a56d3d9`](9a56d3d) fix: don't render an empty selected-count badge in combobox (#3853) - [`4b97f35b`](4b97f35) Make only keyboard focus pin Tooltip open (#3861) - [`2fd63c0e`](2fd63c0) Add Rust SDK to worker heartbeats SDK warning (#3863) - [`4b43f7f9`](4b43f7f) Move Saved Views above Filtering (#3858) - [`cdfd92c9`](cdfd92c) Add option to maximize paginated tables (#3864) - [`ea4d3f14`](ea4d3f1) Fix ConfigurableTableHeadersDrawer for SAA nexus operations (#3867) - [`10f50c8b`](10f50c8) Fix duplicate schedule action keys (#3868) - [`9feeb756`](9feeb75) feat(DT-3944): SignalWithStart system Nexus obfuscation — decode infrastructure + UI (#3356) - [`5af59f03`](5af59f0) feat(workers): let users set the Cloud Run scale-down stabilization window (#3860) - [`bb7d6fbd`](bb7d6fb) Support secondary text on Combobox options (#3866) - [`e0925297`](e092529) Fix paginated table height and mobile scrolling (#3871) - [`6dbd9b96`](6dbd9b9) fix: categorize local activity markers as local-activity (#3872) - [`353fbbc8`](353fbbc) Wrap long filter chips instead of overflowing the filter bar (#3877) - [`f4c71240`](f4c7124) Fix workflow list timestamp formatting (#3880) - [`fa9c51e9`](fa9c51e) Open saved views in a new tab on modifier-click (#3881) - [`376af414`](376af41) feat(workers): warn about Cloud Run deployment latency when creating a version (#3879) - [`1bde3187`](1bde318) Fix extra horizontal scroll from filter bar dropdowns (#3885) - [`dd1dcd9b`](dd1dcd9) Add Next Retry Delay under Retry State (#3886) - [`66c57a40`](66c57a4) Make system saved views additive on custom saved views (#3876) - [`ffa1f2fb`](ffa1f2f) Audit fields for defaults and order (#3887) - [`9ab9c40b`](9ab9c40) fix(server): update labstack/echo/v4 to 4.15.4 (FE-493) (#3874) - [`e4449991`](e444999) Make Build ID filterable and show worker status counts (#3889) - [`0a5e01f5`](0a5e01f) SAA fields audit part 2 (#3890) - [`0d604b5a`](0d604b5) Fix payload input focus loss (FE-643) (#3891) - [`06712381`](0671238) Keep portal menus anchored when the layout shifts (#3893) Co-authored-by: laurakwhit <15069288+laurakwhit@users.noreply.github.com>
Rewrites the grouped-event-buffer so event groups are derived on read and memoized, instead of built at ingest and mutated in place, and gives the module a reactive read API so views stop hand-rolling their own subscribe / throttle / re-read effects.
Correctness
Group identity becomes a pure function of content version: an unchanged group reads back as the same object, a changed one always as a new object. The timeline row pool keys on group identity, so extending a rendered group in place is a recurring source of stale rows — this makes the invariant structural instead of a convention every call site has to remember.
The buffer holds pending activity/nexus metadata and applies it when a group is built, so a terminal event and a stale pending entry resolve the same way in either arrival order, and a head event that arrives after the refresh listing it still picks its own up.
Simplification
ingestHistoryEventreplacesprocessEvent+appendLiveEvent, which each kept their own group store, pending-follower map, and dedup set, plus a reconcile step between them.getEventArray/getWorkflowTaskFailedEventbecome single passes with no sort or dual-store merge..svelte.ts.Views read a value, and only materialize what they render
That replaces ~20 lines of
$state.raw+void $bufferVersion+ rAF + re-read in each of the two workflow layouts, and producers no longer announce their own writes.Nothing upstream of a rendered row needs a full group — the type filter reads
category, the sorts readisPendingandinitialEvent.id, the status filter readsclassification, and segment layout reads the head and last event times.GroupRecordcarries those, sogetLazyGroups()returns the records themselves and views callmaterializeGroupper rendered row: ~54 pooled rows on the timeline, one page of 100 in the compact view.The feed view still takes full groups. Its gutter graph does cross-group layout needing
eventList,level, and reverse event→group lookups (history-graph,positioning) — a separate piece of work.Performance
Measured in Chromium against a production build, running the same scripted interaction on both branches: load a mocked 50k-event history on the timeline, wait for the fetch, then 20 fixed scrolls. Three runs each, medians, run-to-run spread under 2%.
The blocking figures are the ones a user feels — on main that single 355ms task is a visible freeze during load.
Micro-benchmarks on a 200k-event / 80k-group history show where it comes from:
getEventArrayThat last row is the point of deriving on read: opening a view used to build every group in the history, and now builds one screen. The 738ms that remains in ingest is 88%
toEventconversion, which this PR doesn't touch — deferring that is the next available win.API surface
Three changes worth a look, all in published modules:
EventSummaryTable's props are a union discriminated oncompact, soitemsnarrows without a cast and a caller can't pair the wrong list with the wrong mode. Callers whose mode is dynamic spread one object.hoveredEventIdwas a bindable prop onEventSummaryTablebut is only ever set by its own rows, so it became local state. No consumer in this repo or cloud-ui referenced it.enrichGroupsbecomessetPendingMetadata. Pending state is now applied during the initial load rather than only after it completes.cloud-ui imports three of the files this touches — the two view layouts and
workflow-run-layout— and none of their prop contracts changed.Risk
A
LazyGroupand its materializedEventGroupmust agree field for field, or a view filters on one and renders the other. Rather than test two implementations against each other,isPendingandcategorynow share one function, so those cannot diverge; the rules themselves have direct tests. The remaining fields are compared at every prefix of every group type, over a field list whosesatisfies Record<keyof LazyGroup, boolean>makes TypeScript reject a newLazyGroupfield until it is classified.