@lensmcp/core 1.0.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.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Convert one event into a graph node, or return undefined if the event
3
+ * doesn't promote to a node.
4
+ */
5
+ function eventToNode(event) {
6
+ const raw = (event.raw ?? null);
7
+ const kind = raw?.kind;
8
+ const baseContext = event.context;
9
+ const common = {
10
+ id: event.id,
11
+ parentTraceNodeId: baseContext.causedByNodeId ?? baseContext.originNodeId,
12
+ lifecycle: 'completed',
13
+ context: baseContext,
14
+ startTime: event.timestamp,
15
+ attributes: { ...(raw ?? {}) },
16
+ };
17
+ if (event.source === 'react' && kind === 'render') {
18
+ const r = raw;
19
+ return {
20
+ parentAxis: 'render',
21
+ node: {
22
+ ...common,
23
+ type: 'render',
24
+ logicalId: r.render?.componentLogicalId,
25
+ instanceId: r.render?.componentInstanceId,
26
+ name: `render ${r.render?.componentName ?? '?'}`,
27
+ durationMs: r.render?.actualDurationMs,
28
+ },
29
+ };
30
+ }
31
+ if (event.source === 'valtio' && kind === 'state-update') {
32
+ const r = raw;
33
+ return {
34
+ parentAxis: 'state',
35
+ node: {
36
+ ...common,
37
+ type: 'state-update',
38
+ logicalId: r.update?.storeId,
39
+ name: `${r.update?.storeId ?? '?'}.${r.update?.path ?? '?'}`,
40
+ },
41
+ };
42
+ }
43
+ if (event.source === 'nestjs' && kind === 'server-request') {
44
+ const r = raw;
45
+ return {
46
+ parentAxis: 'backend',
47
+ node: {
48
+ ...common,
49
+ type: 'server-request',
50
+ name: `${r.request?.method ?? '?'} ${r.request?.route ?? '?'}`,
51
+ durationMs: r.request?.durationMs,
52
+ },
53
+ };
54
+ }
55
+ if (kind === 'loop') {
56
+ const r = raw;
57
+ return {
58
+ parentAxis: 'performance',
59
+ node: {
60
+ ...common,
61
+ type: 'loop',
62
+ name: 'loop',
63
+ durationMs: r.loop?.durationMs,
64
+ },
65
+ };
66
+ }
67
+ if (event.source === 'client-runtime' && kind === 'user-action') {
68
+ const r = raw;
69
+ return {
70
+ parentAxis: 'caused',
71
+ node: {
72
+ ...common,
73
+ type: 'ui-event',
74
+ logicalId: r.origin?.nodeId,
75
+ name: r.origin?.label ?? event.title,
76
+ },
77
+ };
78
+ }
79
+ return undefined;
80
+ }
81
+ /**
82
+ * Populate `store` from `events`. Edges are created two ways:
83
+ * 1. explicit `causedByNodeId` → an edge on the node's natural axis.
84
+ * 2. same-flow chronological chaining on the 'caused' axis so a flow's
85
+ * nodes form a walkable spine even when explicit causality is absent.
86
+ */
87
+ export function buildGraphFromEvents(store, events) {
88
+ const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
89
+ const mapped = [];
90
+ for (const e of sorted) {
91
+ const m = eventToNode(e);
92
+ if (!m)
93
+ continue;
94
+ store.addNode(m.node);
95
+ mapped.push(m);
96
+ }
97
+ let edgeCount = 0;
98
+ const addEdge = (from, to, axis, type) => {
99
+ const edge = {
100
+ id: `${axis}:${from}->${to}`,
101
+ from,
102
+ to,
103
+ axis,
104
+ type,
105
+ timestamp: store.getNode(to)?.startTime ?? 0,
106
+ };
107
+ store.addEdge(edge);
108
+ edgeCount++;
109
+ };
110
+ // Explicit causedBy edges.
111
+ for (const m of mapped) {
112
+ const caused = m.node.context.causedByNodeId;
113
+ if (caused && store.getNode(caused)) {
114
+ addEdge(caused, m.node.id, m.parentAxis ?? 'caused', 'caused-by');
115
+ }
116
+ }
117
+ // Same-flow chronological spine on the caused axis.
118
+ const byFlow = new Map();
119
+ for (const m of mapped) {
120
+ const flowId = m.node.context.flowId;
121
+ if (!flowId)
122
+ continue;
123
+ let arr = byFlow.get(flowId);
124
+ if (!arr) {
125
+ arr = [];
126
+ byFlow.set(flowId, arr);
127
+ }
128
+ arr.push(m);
129
+ }
130
+ for (const chain of byFlow.values()) {
131
+ for (let i = 1; i < chain.length; i++) {
132
+ const prev = chain[i - 1].node;
133
+ const cur = chain[i].node;
134
+ // Skip if an explicit edge already connects them.
135
+ if (cur.context.causedByNodeId === prev.id)
136
+ continue;
137
+ addEdge(prev.id, cur.id, 'caused', 'flow-sequence');
138
+ }
139
+ }
140
+ return { nodeCount: mapped.length, edgeCount };
141
+ }
@@ -0,0 +1,48 @@
1
+ import type { EdgeAxis, TraceEdge, TraceNode } from '@lensmcp/protocol-types';
2
+ export type Lens = EdgeAxis | readonly EdgeAxis[];
3
+ export type TraversalDirection = 'out' | 'in' | 'both';
4
+ export interface TraverseOptions {
5
+ from: string;
6
+ lens: Lens;
7
+ depth?: number;
8
+ direction?: TraversalDirection;
9
+ /** Cap on visited nodes so a hub node can't blow up tool latency. */
10
+ maxNodes?: number;
11
+ }
12
+ export interface TraverseResult {
13
+ nodes: TraceNode[];
14
+ edges: TraceEdge[];
15
+ }
16
+ /**
17
+ * Typed multi-axis trace graph. Hot-tier only (in-memory). Nodes and
18
+ * edges are indexed for cheap per-axis traversal.
19
+ */
20
+ export declare class GraphStore {
21
+ private readonly nodes;
22
+ private readonly edges;
23
+ private readonly byParent;
24
+ private readonly byFlow;
25
+ private readonly byLogical;
26
+ private readonly byInstance;
27
+ private readonly byRequest;
28
+ /** axis → { from-nodeId → Set<edgeId> } */
29
+ private readonly outByAxis;
30
+ /** axis → { to-nodeId → Set<edgeId> } */
31
+ private readonly inByAxis;
32
+ addNode(node: TraceNode): void;
33
+ updateNode(id: string, patch: Partial<TraceNode>): TraceNode;
34
+ getNode(id: string): TraceNode | undefined;
35
+ addEdge(edge: TraceEdge): void;
36
+ getEdge(id: string): TraceEdge | undefined;
37
+ childrenOf(parentId: string): TraceNode[];
38
+ nodesInFlow(flowId: string): TraceNode[];
39
+ nodesByLogical(logicalId: string): TraceNode[];
40
+ nodesByInstance(instanceId: string): TraceNode[];
41
+ nodesByRequest(requestId: string): TraceNode[];
42
+ traverse(opts: TraverseOptions): TraverseResult;
43
+ nodeCount(): number;
44
+ edgeCount(): number;
45
+ private addToIndex;
46
+ private materialiseNodes;
47
+ }
48
+ //# sourceMappingURL=graph-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph-store.d.ts","sourceRoot":"","sources":["../../src/lib/graph-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAE9E,MAAM,MAAM,IAAI,GAAG,QAAQ,GAAG,SAAS,QAAQ,EAAE,CAAC;AAClD,MAAM,MAAM,kBAAkB,GAAG,KAAK,GAAG,IAAI,GAAG,MAAM,CAAC;AAEvD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,kBAAkB,CAAC;IAC/B,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAKD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,KAAK,EAAE,SAAS,EAAE,CAAC;CACpB;AAED;;;GAGG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;IACtD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgC;IAGtD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkC;IAC3D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkC;IACzD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkC;IAC5D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAkC;IAC7D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkC;IAC5D,2CAA2C;IAC3C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiD;IAC3E,yCAAyC;IACzC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiD;IAE1E,OAAO,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI;IAmB9B,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,SAAS,CAAC,GAAG,SAAS;IAQ5D,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAI1C,OAAO,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI;IAgB9B,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAM1C,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE;IAIzC,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE;IAIxC,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE;IAI9C,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE;IAIhD,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE;IAM9C,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,cAAc;IA8D/C,SAAS,IAAI,MAAM;IAInB,SAAS,IAAI,MAAM;IAMnB,OAAO,CAAC,UAAU;IAalB,OAAO,CAAC,gBAAgB;CASzB"}
@@ -0,0 +1,171 @@
1
+ const DEFAULT_DEPTH = 4;
2
+ const DEFAULT_MAX_NODES = 1000;
3
+ /**
4
+ * Typed multi-axis trace graph. Hot-tier only (in-memory). Nodes and
5
+ * edges are indexed for cheap per-axis traversal.
6
+ */
7
+ export class GraphStore {
8
+ constructor() {
9
+ this.nodes = new Map();
10
+ this.edges = new Map();
11
+ // Indices
12
+ this.byParent = new Map();
13
+ this.byFlow = new Map();
14
+ this.byLogical = new Map();
15
+ this.byInstance = new Map();
16
+ this.byRequest = new Map();
17
+ /** axis → { from-nodeId → Set<edgeId> } */
18
+ this.outByAxis = new Map();
19
+ /** axis → { to-nodeId → Set<edgeId> } */
20
+ this.inByAxis = new Map();
21
+ }
22
+ addNode(node) {
23
+ this.nodes.set(node.id, node);
24
+ if (node.parentTraceNodeId) {
25
+ this.addToIndex(this.byParent, node.parentTraceNodeId, node.id);
26
+ }
27
+ if (node.context.flowId) {
28
+ this.addToIndex(this.byFlow, node.context.flowId, node.id);
29
+ }
30
+ if (node.logicalId) {
31
+ this.addToIndex(this.byLogical, node.logicalId, node.id);
32
+ }
33
+ if (node.instanceId) {
34
+ this.addToIndex(this.byInstance, node.instanceId, node.id);
35
+ }
36
+ if (node.context.requestId) {
37
+ this.addToIndex(this.byRequest, node.context.requestId, node.id);
38
+ }
39
+ }
40
+ updateNode(id, patch) {
41
+ const existing = this.nodes.get(id);
42
+ if (!existing)
43
+ throw new Error(`Unknown node: ${id}`);
44
+ const next = { ...existing, ...patch, id: existing.id };
45
+ this.nodes.set(id, next);
46
+ return next;
47
+ }
48
+ getNode(id) {
49
+ return this.nodes.get(id);
50
+ }
51
+ addEdge(edge) {
52
+ this.edges.set(edge.id, edge);
53
+ let outMap = this.outByAxis.get(edge.axis);
54
+ if (!outMap) {
55
+ outMap = new Map();
56
+ this.outByAxis.set(edge.axis, outMap);
57
+ }
58
+ this.addToIndex(outMap, edge.from, edge.id);
59
+ let inMap = this.inByAxis.get(edge.axis);
60
+ if (!inMap) {
61
+ inMap = new Map();
62
+ this.inByAxis.set(edge.axis, inMap);
63
+ }
64
+ this.addToIndex(inMap, edge.to, edge.id);
65
+ }
66
+ getEdge(id) {
67
+ return this.edges.get(id);
68
+ }
69
+ // ---------- Indexed lookups ----------
70
+ childrenOf(parentId) {
71
+ return this.materialiseNodes(this.byParent.get(parentId));
72
+ }
73
+ nodesInFlow(flowId) {
74
+ return this.materialiseNodes(this.byFlow.get(flowId));
75
+ }
76
+ nodesByLogical(logicalId) {
77
+ return this.materialiseNodes(this.byLogical.get(logicalId));
78
+ }
79
+ nodesByInstance(instanceId) {
80
+ return this.materialiseNodes(this.byInstance.get(instanceId));
81
+ }
82
+ nodesByRequest(requestId) {
83
+ return this.materialiseNodes(this.byRequest.get(requestId));
84
+ }
85
+ // ---------- Lens traversal ----------
86
+ traverse(opts) {
87
+ const start = this.nodes.get(opts.from);
88
+ if (!start)
89
+ return { nodes: [], edges: [] };
90
+ const axes = Array.isArray(opts.lens)
91
+ ? opts.lens
92
+ : [opts.lens];
93
+ const direction = opts.direction ?? 'both';
94
+ const depth = opts.depth ?? DEFAULT_DEPTH;
95
+ const maxNodes = opts.maxNodes ?? DEFAULT_MAX_NODES;
96
+ const visitedNodes = new Set();
97
+ const visitedEdges = new Set();
98
+ const queue = [{ id: start.id, d: 0 }];
99
+ while (queue.length) {
100
+ if (visitedNodes.size >= maxNodes)
101
+ break;
102
+ const { id, d } = queue.shift();
103
+ if (visitedNodes.has(id))
104
+ continue;
105
+ visitedNodes.add(id);
106
+ if (d >= depth)
107
+ continue;
108
+ for (const axis of axes) {
109
+ if (direction === 'out' || direction === 'both') {
110
+ const outMap = this.outByAxis.get(axis);
111
+ const edgeIds = outMap?.get(id);
112
+ if (edgeIds) {
113
+ for (const eid of edgeIds) {
114
+ visitedEdges.add(eid);
115
+ const edge = this.edges.get(eid);
116
+ if (!visitedNodes.has(edge.to)) {
117
+ queue.push({ id: edge.to, d: d + 1 });
118
+ }
119
+ }
120
+ }
121
+ }
122
+ if (direction === 'in' || direction === 'both') {
123
+ const inMap = this.inByAxis.get(axis);
124
+ const edgeIds = inMap?.get(id);
125
+ if (edgeIds) {
126
+ for (const eid of edgeIds) {
127
+ visitedEdges.add(eid);
128
+ const edge = this.edges.get(eid);
129
+ if (!visitedNodes.has(edge.from)) {
130
+ queue.push({ id: edge.from, d: d + 1 });
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ return {
138
+ nodes: this.materialiseNodes(visitedNodes),
139
+ edges: [...visitedEdges]
140
+ .map((eid) => this.edges.get(eid))
141
+ .filter((e) => Boolean(e)),
142
+ };
143
+ }
144
+ // ---------- Stats ----------
145
+ nodeCount() {
146
+ return this.nodes.size;
147
+ }
148
+ edgeCount() {
149
+ return this.edges.size;
150
+ }
151
+ // ---------- internals ----------
152
+ addToIndex(index, key, value) {
153
+ let set = index.get(key);
154
+ if (!set) {
155
+ set = new Set();
156
+ index.set(key, set);
157
+ }
158
+ set.add(value);
159
+ }
160
+ materialiseNodes(ids) {
161
+ if (!ids)
162
+ return [];
163
+ const out = [];
164
+ for (const id of ids) {
165
+ const n = this.nodes.get(id);
166
+ if (n)
167
+ out.push(n);
168
+ }
169
+ return out;
170
+ }
171
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Pattern detectors. Each is a pure function over a window of
3
+ * `BaseEvent`s (or already-extracted records) and returns zero or more
4
+ * `PatternMatch`es. Reducers call these on a streaming window; tools
5
+ * call them on demand.
6
+ *
7
+ * Detectors covered (Phase 7):
8
+ * - db-in-loop — a `loop` node completed with dbCallsInsideLoop > N
9
+ * - n+1-query — the same query signature repeated ≥ N times in a window
10
+ * - slow-path — the longest cumulative duration on the caused/async axis
11
+ * - render-storm — one componentInstanceId rendered > N times in < T ms
12
+ */
13
+ import type { BaseEvent } from '@lensmcp/protocol-types';
14
+ export type PatternKind = 'db-in-loop' | 'n+1-query' | 'slow-path' | 'render-storm' | 'leak';
15
+ export interface PatternMatch {
16
+ kind: PatternKind;
17
+ severity: 'info' | 'warning' | 'error';
18
+ title: string;
19
+ /** Node/event ids that evidence the pattern. */
20
+ evidence: string[];
21
+ /** Free-form per-pattern details. */
22
+ details: Record<string, unknown>;
23
+ detectedAt: number;
24
+ }
25
+ export interface DbInLoopOptions {
26
+ /** Minimum DB calls inside a loop before it's flagged. Default 5. */
27
+ threshold?: number;
28
+ }
29
+ /**
30
+ * Flag `loop` events whose `dbCallsInsideLoop` exceeds the threshold.
31
+ * Events are expected to carry `raw.kind === 'loop'` with a `loop`
32
+ * attribute matching `LoopAttrs`.
33
+ */
34
+ export declare function detectDbInLoop(events: readonly BaseEvent[], options?: DbInLoopOptions): PatternMatch[];
35
+ export interface NPlusOneOptions {
36
+ /** Minimum repeats of an identical signature before flagging. Default 3. */
37
+ threshold?: number;
38
+ }
39
+ /**
40
+ * Flag repeated identical DB query signatures. Events are expected to
41
+ * carry `raw.kind === 'db-query'` with `raw.query.signature`.
42
+ */
43
+ export declare function detectNPlusOne(events: readonly BaseEvent[], options?: NPlusOneOptions): PatternMatch[];
44
+ export interface RenderStormOptions {
45
+ /** Max renders per component in the window before flagging. Default 5. */
46
+ threshold?: number;
47
+ /** Window in ms. Default 250. */
48
+ windowMs?: number;
49
+ }
50
+ /**
51
+ * Flag a component instance that rendered more than `threshold` times
52
+ * inside any `windowMs` window. Events are `source:'react'`,
53
+ * `raw.kind === 'render'`, `raw.render.componentInstanceId`.
54
+ */
55
+ export declare function detectRenderStorm(events: readonly BaseEvent[], options?: RenderStormOptions): PatternMatch[];
56
+ export interface SlowPathResult {
57
+ /** Ordered node/event ids along the slowest cumulative chain. */
58
+ path: string[];
59
+ totalDurationMs: number;
60
+ }
61
+ /**
62
+ * Find the single slowest cumulative chain on the caused/async axis,
63
+ * given a precomputed adjacency (nodeId → outgoing nodeIds) and a
64
+ * per-node duration map. Pure + bounded (visits each node once via
65
+ * memoised DFS).
66
+ */
67
+ export declare function findSlowPath(roots: readonly string[], adjacency: ReadonlyMap<string, readonly string[]>, durationOf: (nodeId: string) => number): SlowPathResult;
68
+ //# sourceMappingURL=patterns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patterns.d.ts","sourceRoot":"","sources":["../../src/lib/patterns.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD,MAAM,MAAM,WAAW,GACnB,YAAY,GACZ,WAAW,GACX,WAAW,GACX,cAAc,GACd,MAAM,CAAC;AAEX,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,SAAS,SAAS,EAAE,EAC5B,OAAO,GAAE,eAAoB,GAC5B,YAAY,EAAE,CA0BhB;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,SAAS,SAAS,EAAE,EAC5B,OAAO,GAAE,eAAoB,GAC5B,YAAY,EAAE,CA4BhB;AAED,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,SAAS,SAAS,EAAE,EAC5B,OAAO,GAAE,kBAAuB,GAC/B,YAAY,EAAE,CAkDhB;AAED,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,SAAS,MAAM,EAAE,EACxB,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,EACjD,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GACrC,cAAc,CAmChB"}
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Flag `loop` events whose `dbCallsInsideLoop` exceeds the threshold.
3
+ * Events are expected to carry `raw.kind === 'loop'` with a `loop`
4
+ * attribute matching `LoopAttrs`.
5
+ */
6
+ export function detectDbInLoop(events, options = {}) {
7
+ const threshold = options.threshold ?? 5;
8
+ const out = [];
9
+ for (const e of events) {
10
+ const raw = (e.raw ?? null);
11
+ if (raw?.kind !== 'loop' || !raw.loop)
12
+ continue;
13
+ const dbCalls = raw.loop.dbCallsInsideLoop ?? 0;
14
+ if (dbCalls > threshold) {
15
+ out.push({
16
+ kind: 'db-in-loop',
17
+ severity: 'warning',
18
+ title: `${dbCalls} DB calls inside a loop (${raw.loop.iterations ?? '?'} iterations)`,
19
+ evidence: [e.id],
20
+ details: {
21
+ dbCallsInsideLoop: dbCalls,
22
+ iterations: raw.loop.iterations,
23
+ durationMs: raw.loop.durationMs,
24
+ threshold,
25
+ },
26
+ detectedAt: e.timestamp,
27
+ });
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+ /**
33
+ * Flag repeated identical DB query signatures. Events are expected to
34
+ * carry `raw.kind === 'db-query'` with `raw.query.signature`.
35
+ */
36
+ export function detectNPlusOne(events, options = {}) {
37
+ const threshold = options.threshold ?? 3;
38
+ const groups = new Map();
39
+ for (const e of events) {
40
+ const raw = (e.raw ?? null);
41
+ if (raw?.kind !== 'db-query' || !raw.query?.signature)
42
+ continue;
43
+ const sig = raw.query.signature;
44
+ let arr = groups.get(sig);
45
+ if (!arr) {
46
+ arr = [];
47
+ groups.set(sig, arr);
48
+ }
49
+ arr.push(e);
50
+ }
51
+ const out = [];
52
+ for (const [signature, group] of groups) {
53
+ if (group.length >= threshold) {
54
+ out.push({
55
+ kind: 'n+1-query',
56
+ severity: 'warning',
57
+ title: `Query repeated ${group.length}× — likely N+1`,
58
+ evidence: group.map((e) => e.id),
59
+ details: { signature, count: group.length, threshold },
60
+ detectedAt: group[group.length - 1].timestamp,
61
+ });
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ /**
67
+ * Flag a component instance that rendered more than `threshold` times
68
+ * inside any `windowMs` window. Events are `source:'react'`,
69
+ * `raw.kind === 'render'`, `raw.render.componentInstanceId`.
70
+ */
71
+ export function detectRenderStorm(events, options = {}) {
72
+ const threshold = options.threshold ?? 5;
73
+ const windowMs = options.windowMs ?? 250;
74
+ const byInstance = new Map();
75
+ for (const e of events) {
76
+ if (e.source !== 'react')
77
+ continue;
78
+ const raw = (e.raw ?? null);
79
+ if (raw?.kind !== 'render' || !raw.render?.componentInstanceId)
80
+ continue;
81
+ const id = raw.render.componentInstanceId;
82
+ let arr = byInstance.get(id);
83
+ if (!arr) {
84
+ arr = [];
85
+ byInstance.set(id, arr);
86
+ }
87
+ arr.push(e);
88
+ }
89
+ const out = [];
90
+ for (const [instanceId, group] of byInstance) {
91
+ group.sort((a, b) => a.timestamp - b.timestamp);
92
+ // Sliding window: find the densest run.
93
+ let start = 0;
94
+ for (let end = 0; end < group.length; end++) {
95
+ while (group[end].timestamp - group[start].timestamp > windowMs) {
96
+ start++;
97
+ }
98
+ const count = end - start + 1;
99
+ if (count > threshold) {
100
+ const window = group.slice(start, end + 1);
101
+ const name = window[0].raw.render
102
+ ?.componentName ?? instanceId;
103
+ out.push({
104
+ kind: 'render-storm',
105
+ severity: 'warning',
106
+ title: `${name} rendered ${count}× in ${windowMs}ms`,
107
+ evidence: window.map((e) => e.id),
108
+ details: {
109
+ componentInstanceId: instanceId,
110
+ count,
111
+ windowMs,
112
+ threshold,
113
+ },
114
+ detectedAt: window[window.length - 1].timestamp,
115
+ });
116
+ break; // one match per instance is enough
117
+ }
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * Find the single slowest cumulative chain on the caused/async axis,
124
+ * given a precomputed adjacency (nodeId → outgoing nodeIds) and a
125
+ * per-node duration map. Pure + bounded (visits each node once via
126
+ * memoised DFS).
127
+ */
128
+ export function findSlowPath(roots, adjacency, durationOf) {
129
+ const memo = new Map();
130
+ const inProgress = new Set();
131
+ function best(nodeId) {
132
+ const cached = memo.get(nodeId);
133
+ if (cached)
134
+ return cached;
135
+ if (inProgress.has(nodeId)) {
136
+ // cycle guard
137
+ return { path: [nodeId], totalDurationMs: durationOf(nodeId) };
138
+ }
139
+ inProgress.add(nodeId);
140
+ const selfMs = durationOf(nodeId);
141
+ const children = adjacency.get(nodeId) ?? [];
142
+ let bestChild;
143
+ for (const child of children) {
144
+ const r = best(child);
145
+ if (!bestChild || r.totalDurationMs > bestChild.totalDurationMs) {
146
+ bestChild = r;
147
+ }
148
+ }
149
+ inProgress.delete(nodeId);
150
+ const result = bestChild
151
+ ? { path: [nodeId, ...bestChild.path], totalDurationMs: selfMs + bestChild.totalDurationMs }
152
+ : { path: [nodeId], totalDurationMs: selfMs };
153
+ memo.set(nodeId, result);
154
+ return result;
155
+ }
156
+ let overall = { path: [], totalDurationMs: 0 };
157
+ for (const root of roots) {
158
+ const r = best(root);
159
+ if (r.totalDurationMs > overall.totalDurationMs)
160
+ overall = r;
161
+ }
162
+ return overall;
163
+ }
@@ -0,0 +1,20 @@
1
+ import type { BaseEvent } from '@lensmcp/protocol-types';
2
+ /**
3
+ * A reducer consumes events from the bus and may mutate the graph store
4
+ * and/or mark resources dirty. We deliberately do not pass a "world"
5
+ * object here — reducers are registered with their dependencies via
6
+ * closures by `bootstrapLensmcp()`.
7
+ */
8
+ export type Reducer = (event: BaseEvent) => void | Promise<void>;
9
+ export interface ReducerEntry {
10
+ name: string;
11
+ reducer: Reducer;
12
+ }
13
+ export declare class ReducerRegistry {
14
+ private readonly reducers;
15
+ register(name: string, reducer: Reducer): void;
16
+ list(): readonly ReducerEntry[];
17
+ /** Run every registered reducer against one event. */
18
+ dispatch(event: BaseEvent): Promise<void>;
19
+ }
20
+ //# sourceMappingURL=reducer-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reducer-registry.d.ts","sourceRoot":"","sources":["../../src/lib/reducer-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAEjE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsB;IAE/C,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI;IAO9C,IAAI,IAAI,SAAS,YAAY,EAAE;IAI/B,sDAAsD;IAChD,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhD"}
@@ -0,0 +1,28 @@
1
+ export class ReducerRegistry {
2
+ constructor() {
3
+ this.reducers = [];
4
+ }
5
+ register(name, reducer) {
6
+ if (this.reducers.some((r) => r.name === name)) {
7
+ throw new Error(`Reducer already registered: ${name}`);
8
+ }
9
+ this.reducers.push({ name, reducer });
10
+ }
11
+ list() {
12
+ return this.reducers;
13
+ }
14
+ /** Run every registered reducer against one event. */
15
+ async dispatch(event) {
16
+ for (const { reducer } of this.reducers) {
17
+ try {
18
+ const r = reducer(event);
19
+ if (r && typeof r.then === 'function') {
20
+ await r;
21
+ }
22
+ }
23
+ catch {
24
+ /* swallow reducer errors */
25
+ }
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,31 @@
1
+ import type { Unsubscribe } from './common.js';
2
+ /**
3
+ * Anything that JSON.stringify can safely serialise. Intentionally loose:
4
+ * reducers compose plain objects with optional fields (which structurally
5
+ * don't fit a strict recursive `JsonValue` type) and we'd rather not
6
+ * pollute every call site with casts.
7
+ */
8
+ export type ResourceValue = unknown;
9
+ export type ResourceProducer = () => ResourceValue | Promise<ResourceValue>;
10
+ export type ResourceUpdatedHandler = (uri: string, revision: number) => void;
11
+ /**
12
+ * The resource store. URIs are registered once; reading is cheap; the
13
+ * `markDirty` call bumps the revision counter and notifies subscribers
14
+ * only when the produced JSON actually changes (diff-aware).
15
+ */
16
+ export declare class ResourceStore {
17
+ private readonly entries;
18
+ private readonly subs;
19
+ register(uri: string, produce: ResourceProducer): Unsubscribe;
20
+ read(uri: string): Promise<ResourceValue>;
21
+ list(): string[];
22
+ /**
23
+ * Recompute the JSON, bump the revision counter if it differs, and
24
+ * notify subscribers. No-op if the URI is unknown or the JSON didn't
25
+ * change.
26
+ */
27
+ markDirty(uri: string): Promise<void>;
28
+ revision(uri: string): number | undefined;
29
+ subscribe(uri: string, handler: ResourceUpdatedHandler): Unsubscribe;
30
+ }
31
+ //# sourceMappingURL=resource-store.d.ts.map