From 0c9788a1828fb72a466c45f78d3397a28a47d5f6 Mon Sep 17 00:00:00 2001
From: racct <elias@adornis.de>
Date: Fri, 14 Feb 2025 10:08:18 +0000
Subject: [PATCH 1/2] feat: use single nodes for every (sub)operation

---
 lab/introspect/api/get-introspect-node.ts     |  52 +++
 .../client/x-introspect-viewer-node.ts        | 289 +++++++++++++
 lab/introspect/client/x-introspect-viewer.ts  | 394 +++++-------------
 .../entities/introspect-node.entity.ts        |  88 ++++
 lab/introspect/introspect-node.ts             |  83 ++--
 lab/introspect/server/introspect.ts           |  53 +--
 lab/introspect/server/introspectInjector.ts   | 146 ++-----
 .../server/introspection-options.ts           |  62 +++
 lab/introspect/server/track-operation.ts      |  88 ++++
 lab/introspect/startup.test.ts                |  12 +
 10 files changed, 789 insertions(+), 478 deletions(-)
 create mode 100644 lab/introspect/api/get-introspect-node.ts
 create mode 100644 lab/introspect/client/x-introspect-viewer-node.ts
 create mode 100644 lab/introspect/entities/introspect-node.entity.ts
 create mode 100644 lab/introspect/server/introspection-options.ts
 create mode 100644 lab/introspect/server/track-operation.ts
 create mode 100644 lab/introspect/startup.test.ts

diff --git a/lab/introspect/api/get-introspect-node.ts b/lab/introspect/api/get-introspect-node.ts
new file mode 100644
index 0000000000..7303e87754
--- /dev/null
+++ b/lab/introspect/api/get-introspect-node.ts
@@ -0,0 +1,52 @@
+import { ID } from '@adornis/baseql/baseqlTypes.js';
+import type { EntityData } from '@adornis/baseql/entities/types.js';
+import { registerQuery } from '@adornis/baseql/metadata/register.js';
+import { getRawCollection } from '@adornis/baseql/server/collections.js';
+import type { BaseQLSelectionSet } from '@adornis/baseql/utils/queryGeneration.js';
+import { IntrospectNode } from '../entities/introspect-node.entity.js';
+import { getIntrospectCollectionName } from '../server/introspection-options.js';
+
+export const getIntrospectNodeByID = registerQuery({
+  operationName: 'getIntrospectNodeByID',
+  type: () => IntrospectNode,
+  params: [{ name: 'nodeID', type: () => ID }],
+  resolve: (nodeID: string) => async (gqlFields: BaseQLSelectionSet<IntrospectNode>) => {
+    const collection = await getRawCollection<EntityData<IntrospectNode>>(getIntrospectCollectionName());
+    const node = await collection.findOne({ _id: nodeID });
+    return node as IntrospectNode | null;
+  },
+});
+
+export const getLastIntrospectNodeByOperation = registerQuery({
+  operationName: 'getLastIntrospectNodeByOperation',
+  type: () => IntrospectNode,
+  params: [{ name: 'operationName', type: () => String }],
+  resolve: (operationName: string) => async (gqlFields: BaseQLSelectionSet<IntrospectNode>) => {
+    const collection = await getRawCollection<EntityData<IntrospectNode>>(getIntrospectCollectionName());
+    const node = await collection.find({ operation: operationName }).sort({ startedAt: -1 }).limit(1).next();
+    return node as IntrospectNode | null;
+  },
+});
+
+export const getLastIntrospectNodeByOperationAndParam = registerQuery({
+  operationName: 'getLastIntrospectNodeByOperationAndParam',
+  type: () => IntrospectNode,
+  params: [
+    { name: 'operationName', type: () => String },
+    { name: 'paramsStringified', type: () => String },
+  ],
+  resolve:
+    (operationName: string, paramsStringified: string) => async (gqlFields: BaseQLSelectionSet<IntrospectNode>) => {
+      const params = JSON.parse(paramsStringified);
+      const collection = await getRawCollection<EntityData<IntrospectNode>>(getIntrospectCollectionName());
+      const node = await collection
+        .find({
+          operation: operationName,
+          ...Object.fromEntries(Object.entries(params).map(([key, value]) => [`args.${key}`, value])),
+        })
+        .sort({ startedAt: -1 })
+        .limit(1)
+        .next();
+      return node as IntrospectNode | null;
+    },
+});
diff --git a/lab/introspect/client/x-introspect-viewer-node.ts b/lab/introspect/client/x-introspect-viewer-node.ts
new file mode 100644
index 0000000000..c87df27daa
--- /dev/null
+++ b/lab/introspect/client/x-introspect-viewer-node.ts
@@ -0,0 +1,289 @@
+import type { Maybe } from '@adornis/base/utilTypes.js';
+import { AdornisEntity } from '@adornis/baseql/entities/adornisEntity.js';
+import { constructValue } from '@adornis/baseql/entities/construct.js';
+import { selectionSet } from '@adornis/baseql/utils/queryGeneration.js';
+import { acss } from '@adornis/chemistry/directives/acss.js';
+import { css } from '@adornis/chemistry/directives/css.js';
+import '@adornis/chemistry/elements/components/x-loader.js';
+import { DesignSystem } from '@adornis/chemistry/elements/theming/design.js';
+import { stateful, useObservable } from '@adornis/functional-lit/stateful.js';
+import { useQuery } from '@adornis/functional-lit/use-query.js';
+import { GeneratedForm } from '@adornis/generated-form/generated-form.js';
+import { useGeneratedForm } from '@adornis/generated-form/use-generic-form.js';
+import { html } from 'lit';
+import { repeat } from 'lit/directives/repeat.js';
+import { html as htmlStatic, unsafeStatic } from 'lit/static-html.js';
+import { of } from 'rxjs';
+import { getIntrospectNodeByID } from '../api/get-introspect-node.js';
+import { IntrospectNode } from '../entities/introspect-node.entity.js';
+import type { IIntrospectNode } from '../introspect-node.js';
+
+const cellBorder = {
+  borderBottom: '1px solid rgba(0,0,0,0.1)',
+  borderRight: '1px solid rgba(0,0,0,0.1)',
+  borderLeft: '1px solid rgba(0,0,0,0.1)',
+};
+
+const isGeneratedForm = (data: any): 'array' | 'object' | 'values' | false => {
+  if (Array.isArray(data)) return 'array';
+  if (data?._class) return 'object';
+  if (data) {
+    const values = Object.values(data) as any[];
+    for (let i = 0; i < values.length; i++) {
+      if (values[i]?._class) {
+        return 'values';
+      }
+    }
+  }
+  return false;
+};
+
+const genBlacklist = (data: Maybe<AdornisEntity>) => {
+  if (!data) {
+    return [];
+  }
+  return Object.keys((data as any).docMeta)
+    .map(key => {
+      if (!data.has(key as any)) {
+        return key;
+      }
+      return null;
+    })
+    .filter(Boolean) as string[];
+};
+
+export const IntrospectViewerNodeID = stateful(({ nodeID }: { nodeID?: string }) => {
+  const introspectionNode = useQuery(
+    nodeID ? getIntrospectNodeByID(nodeID)(selectionSet(() => IntrospectNode, 1)) : of(null),
+    [nodeID],
+  );
+
+  if (!nodeID)
+    return html`
+      <x-empty-placeholder .img=${'close'} .text=${'Keine Instrospection node übergeben.'}></x-empty-placeholder>
+    `;
+
+  return IntrospectViewerNode(introspectionNode);
+});
+
+export const IntrospectViewerNode = stateful(
+  (introspectionNode: { value: IntrospectNode | null; loading: boolean; error: Error | null }) => {
+    const design = useObservable(() => DesignSystem.currentTheme, []);
+
+    if (introspectionNode.loading)
+      return html`
+        <x-flex
+          flex
+          center
+          crossaxis-center
+          padding="md"
+          ${css({
+            background: 'white',
+            minWidth: '200px',
+            maxWidth: '350px',
+            borderRadius: '0.375rem',
+            border: '1px solid rgba(0,0,0,0.1)',
+          })}
+        >
+          <x-loader></x-loader>
+        </x-flex>
+      `;
+    if (introspectionNode.error)
+      return html`
+        <x-empty-placeholder .img=${'close'} .text=${introspectionNode.error.message}></x-empty-placeholder>
+      `;
+    if (!introspectionNode.value)
+      return html`
+        <x-empty-placeholder .img=${'close'} .text=${'Keine Instrospection node gefunden.'}></x-empty-placeholder>
+      `;
+
+    const dbNode = introspectionNode.value;
+    const node: IIntrospectNode & { childIDs: string[] } = {
+      ...introspectionNode.value.toObject(),
+      meta: dbNode.meta ? JSON.parse(dbNode.meta) : null,
+      result: dbNode.result ? JSON.parse(dbNode.result) : null,
+      errors: dbNode.errors ? JSON.parse(dbNode.errors) : null,
+      args: dbNode.args ? JSON.parse(dbNode.args) : null,
+    } as unknown as IIntrospectNode & { childIDs: string[] };
+
+    const nodeDataContent = (data: any, path: string[] = []) => {
+      const genForm = isGeneratedForm(data);
+      if (genForm) {
+        switch (genForm) {
+          case 'array':
+            return repeat(data, (res: any, i) => nodeDataContent(res, [...path, i.toString()]));
+          case 'object':
+            return stateful(() => {
+              const entity = fillEmptyFields(constructValue(data));
+              const { form } = useGeneratedForm(entity, {
+                disabledFields: entity ? selectionSet(() => entity.constructor as typeof AdornisEntity, 1) : {},
+              });
+              return html`
+                <x-text tone="subtle"> ${data._class} </x-text>
+                ${GeneratedForm({ form, blacklist: genBlacklist(form.document) })}
+              `;
+            })();
+          case 'values':
+            return repeat(Object.entries(data), ([key, val]) => {
+              return nodeDataContent(val, [...path, key]);
+            });
+        }
+      }
+
+      return html`
+        <x-flex space="sm">
+          <x-text tone="subtle"> ${path.join(' > ')} </x-text>
+          <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}
+            >${JSON.stringify(data, null, 2)}</x-text
+          >
+        </x-flex>
+      `;
+    };
+
+    const customComp = node.meta['component'];
+
+    return html`
+      <x-flex
+        ${css({
+          display: 'inline-flex',
+          background: 'white',
+          minWidth: '250px',
+          maxWidth: '450px',
+          flexShrink: '0',
+          borderRadius: design?.sizes.borderRadiusSecondary,
+        })}
+      >
+        <x-flex
+          padding="sm"
+          space-between
+          horizontal
+          crossaxis-center
+          ${css({
+            background: design?.colors.accent,
+            color: 'white',
+            borderRadius: `${design?.sizes.borderRadiusSecondary} ${design?.sizes.borderRadiusSecondary} 0 0`,
+          })}
+          ${acss({
+            cursor: 'pointer',
+            '&:hover': {
+              opacity: 0.5,
+            },
+          })}
+          @click=${() => {
+            // this._closed[path.join('.')] = !this._closed[path.join('.')];
+            // if (this._closed[path.join('.')]) {
+            //   // close of child nodes
+            //   this._closeAllChildNodes(node, path);
+            // }
+            // this.requestUpdate();
+          }}
+        >
+          <x-text>${node.operation}</x-text>
+          <x-icon> ${closed ? 'unfold_more' : 'unfold_less'} </x-icon>
+        </x-flex>
+        ${closed
+          ? html`
+              <x-flex ${css({ border: '1px solid rgba(0,0,0,0.1)', flexGrow: 1 })} padding="sm">
+                <x-text ${css({ opacity: 0.5 })}>Expand to see more...</x-text>
+              </x-flex>
+            `
+          : html`
+              <x-flex
+                flex
+                ${css({
+                  background: 'white',
+                  minWidth: '200px',
+                  maxWidth: '350px',
+                  borderRadius: '0.375rem',
+                  borderTopRightRadius: '0',
+                  borderTopLeftRadius: '0',
+                  border: '1px solid rgba(0,0,0,0.1)',
+                })}
+              >
+                <x-flex
+                  padding="sm"
+                  space-between
+                  horizontal
+                  crossaxis-center
+                  ${css({
+                    background: 'var(--color-accent)',
+                    color: 'white',
+                    borderRadius: '0.375rem 0.375rem 0 0',
+                  })}
+                >
+                  <x-text>${node.operation}</x-text>
+                </x-flex>
+
+                ${customComp
+                  ? htmlStatic`<x-flex padding="sm" space="sm" ${css(cellBorder)}>
+      <${unsafeStatic(customComp)} .value=${node}></${unsafeStatic(customComp)}>
+    </x-flex>`
+                  : html`
+                      <x-flex padding="sm" space="sm" ${css(cellBorder)}>
+                        <x-text ${css({ padding: '0.25rem', background: 'var(--color-accent-50)' })}>Arguments</x-text>
+                        ${nodeDataContent(node.args, [])}
+                      </x-flex>
+
+                      <x-flex padding="sm" space="sm" ${css(cellBorder)}>
+                        <x-text ${css({ padding: '0.25rem', background: 'var(--color-accent-50)' })}>Result</x-text>
+                        ${nodeDataContent(node.result, [])}
+                      </x-flex>
+                    `}
+                ${errors(node)} ${metaInfo(node)} ${timeStats(node)}
+              </x-flex>
+            `}
+      </x-flex>
+    `;
+  },
+);
+
+const errors = (node: IIntrospectNode) => html`
+  <x-flex padding="sm" space="sm" ${css(cellBorder)}>
+    <x-text ${css({ padding: '0.25rem', background: 'var(--color-accent-50)' })}>Errors</x-text>
+    <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}
+      >${JSON.stringify(node.errors, null, 2)}</x-text
+    >
+  </x-flex>
+`;
+
+const metaInfo = (node: IIntrospectNode) => html`
+  <x-flex padding="sm" space="sm" ${css(cellBorder)}>
+    <x-text ${css({ padding: '0.25rem', background: 'var(--color-accent-50)' })}>Meta</x-text>
+    <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}
+      >${JSON.stringify(node.meta, null, 2)}</x-text
+    >
+  </x-flex>
+`;
+
+const timeStats = (node: IIntrospectNode) => {
+  const startedAt = node.startedAt ? new Date(node.startedAt).toLocaleString() : 'No Start Time';
+  const finishedAt = node.finishedAt ? new Date(node.finishedAt).toLocaleString() : 'No Finish Time';
+  const elapsed = node.startedAt && node.finishedAt ? node.finishedAt.getTime() - node.startedAt.getTime() : 0;
+
+  return html`
+    <x-flex ${css({ ...cellBorder, flexGrow: 1 })}>
+      <x-grid columns="1fr 1fr 1fr" padding="sm" space="sm">
+        <x-text>Started</x-text>
+        <x-text>Finished</x-text>
+        <x-text>Elapsed</x-text>
+        <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}> ${startedAt} </x-text>
+        <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}> ${finishedAt} </x-text>
+        <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}> ${elapsed}ms </x-text>
+      </x-grid>
+    </x-flex>
+  `;
+};
+
+// TODO dirty hack for accessing properties that were not requested
+const fillEmptyFields = (data: Maybe<AdornisEntity>) => {
+  if (!data) return data;
+
+  Object.values(data.fields).forEach(field => {
+    if (!data.has(field.name as any)) {
+      data[field.name] = null;
+      return;
+    }
+    if (data[field.name] instanceof AdornisEntity) fillEmptyFields(data[field.name]);
+  });
+  return data;
+};
diff --git a/lab/introspect/client/x-introspect-viewer.ts b/lab/introspect/client/x-introspect-viewer.ts
index f0ede5b462..1a0e306f31 100644
--- a/lab/introspect/client/x-introspect-viewer.ts
+++ b/lab/introspect/client/x-introspect-viewer.ts
@@ -1,47 +1,48 @@
-import type { Maybe } from '@adornis/base/utilTypes.js';
-import { AdornisEntity } from '@adornis/baseql/entities/adornisEntity.js';
-import { constructValue } from '@adornis/baseql/entities/construct.js';
+import { selectionSet } from '@adornis/baseql/utils/queryGeneration.js';
 import { ChemistryLitElement } from '@adornis/chemistry/chemistry-lit-element.js';
-import { acss } from '@adornis/chemistry/directives/acss.js';
+import { RXController } from '@adornis/chemistry/controllers/RXController.js';
 import { css } from '@adornis/chemistry/directives/css.js';
 import '@adornis/cms/client/generated-form.js';
-import { GenericFieldFormController } from '@adornis/cms/client/generic-field-formcontroller.js';
-import { FormController } from '@adornis/forms/x-form-controller.js';
-import { html, nothing, type PropertyValues } from 'lit';
+import { stateful } from '@adornis/functional-lit/stateful.js';
+import { useQuery } from '@adornis/functional-lit/use-query.js';
+import { html, nothing } from 'lit';
 import { customElement, property, state } from 'lit/decorators.js';
 import { createRef, ref, type Ref } from 'lit/directives/ref.js';
 import { repeat } from 'lit/directives/repeat.js';
-import { html as htmlStatic, unsafeStatic } from 'lit/static-html.js';
-import type { IntrospectNode } from '../introspect-node.js';
-
-const cellBorder = {
-  borderBottom: '1px solid rgba(0,0,0,0.1)',
-  borderRight: '1px solid rgba(0,0,0,0.1)',
-  borderLeft: '1px solid rgba(0,0,0,0.1)',
-};
+import { of, switchMap } from 'rxjs';
+import { getIntrospectNodeByID } from '../api/get-introspect-node.js';
+import { IntrospectNode } from '../entities/introspect-node.entity.js';
+import { IntrospectViewerNode } from './x-introspect-viewer-node.js';
 
 @customElement('x-introspect-viewer')
 export class XIntrospectViewer extends ChemistryLitElement {
-  @property({ attribute: false }) node: Maybe<IntrospectNode & { _id?: string }> = null;
-
-  @state() private _closed: Record<string, boolean> = {};
-  @state() private _forms: Record<string, FormController<AdornisEntity>> = {};
-
-  override updated(changedProperties: PropertyValues): void {
-    if (changedProperties.has('node') && this.node)
-      this.node.children?.forEach((childNode, i) => {
-        this._closed['root.' + i] = true;
-        return this._closeAllChildNodes(childNode, ['root', i.toString()]);
-      });
-
-    super.updated(changedProperties);
-  }
+  @property({ attribute: false }) nodeID = new RXController<string | null>(this, null);
+
+  @state() private _node = new RXController(
+    this,
+    this.nodeID.observable.pipe(
+      switchMap(nodeID => {
+        if (!nodeID) return of(null);
+        return getIntrospectNodeByID(nodeID)(selectionSet(() => IntrospectNode, 1));
+      }),
+    ),
+  );
+
+  // override updated(changedProperties: PropertyValues): void {
+  //   if (changedProperties.has('node') && this._node)
+  //     this._node.children?.forEach((childNode, i) => {
+  //       this._closed['root.' + i] = true;
+  //       return this._closeAllChildNodes(childNode, ['root', i.toString()]);
+  //     });
+
+  //   super.updated(changedProperties);
+  // }
 
   private _header() {
     const values = {
-      _id: this.node?._id,
-      userID: this.node?.userID ?? 'No User ID',
-      sessionID: this.node?.sessionID ?? 'No Session ID',
+      _id: this._node.value?._id,
+      userID: this._node.value?.userID ?? 'No User ID',
+      sessionID: this._node.value?.sessionID ?? 'No Session ID',
     };
 
     return html`
@@ -86,256 +87,72 @@ export class XIntrospectViewer extends ChemistryLitElement {
 
   bgRef: Ref<HTMLElement> = createRef();
 
-  private _isGeneratedForm(data: any): 'array' | 'object' | 'values' | false {
-    if (Array.isArray(data)) {
-      if (data?.[0]?._class) {
-        return 'array';
-      }
-    }
-    if (data?._class) {
-      return 'object';
-    }
-    if (data) {
-      const values = Object.values(data) as any[];
-      for (let i = 0; i < values.length; i++) {
-        if (values[i]?._class) {
-          return 'values';
-        }
-      }
-    }
-    return false;
-  }
-
-  private _genBlacklist(data: Maybe<AdornisEntity>) {
-    if (!data) {
-      return [];
-    }
-    return Object.keys((data as any).docMeta)
-      .map(key => {
-        if (!data.has(key as any)) {
-          return key;
-        }
-        return null;
-      })
-      .filter(Boolean) as string[];
-  }
-
-  // TODO dirty hack for accessing properties that were not requested
-  private _fillEmptyFields(data: Maybe<AdornisEntity>) {
-    if (!data) return data;
-
-    Object.values(data.fields).forEach(field => {
-      if (!data.has(field.name as any)) {
-        data[field.name] = null;
-        return;
-      }
-      if (data[field.name] instanceof AdornisEntity) this._fillEmptyFields(data[field.name]);
-    });
-    return data;
-  }
-
-  private _nodeDataContent(data: any, path: string[] = []) {
-    const genForm = this._isGeneratedForm(data);
-    if (genForm) {
-      switch (genForm) {
-        case 'array':
-          return repeat(data, (res: any, i) => {
-            const id = path.join('.') + '#' + i;
-            this._forms[id] ??= new GenericFieldFormController(this, this._fillEmptyFields(constructValue(res)), {
-              enabledFields: {},
-            });
-            return html`
-              <x-text tone="subtle"> ${res._class} #${i + 1} </x-text>
-              <generated-form
-                .formController=${this._forms[id]!}
-                .blacklist=${this._genBlacklist(this._forms[id]?.document)}
-              ></generated-form>
-            `;
-          });
-        case 'object':
-          const id = path.join('.');
-          this._forms[id] ??= new GenericFieldFormController(this, this._fillEmptyFields(constructValue(data)), {
-            enabledFields: {},
-          });
-          return html`
-            <x-text tone="subtle"> ${data._class} </x-text>
-            <generated-form
-              .formController=${this._forms[id]!}
-              .blacklist=${this._genBlacklist(this._forms[id]?.document)}
-            ></generated-form>
-          `;
-        case 'values':
-          return repeat(Object.entries(data), ([key, val]) => {
-            return this._nodeDataContent(val, [...path, key]);
-            // const id = path.join('.') + '#' + key;
-            // this._forms[id] ??= new GenericFieldFormController(this, this._fillEmptyFields(constructValue(val)), {
-            //   enabledFields: {},
-            // });
-            // return html`
-            //   <x-text> ${key} </x-text>
-            //   <generated-form
-            //     .formController=${this._forms[id]!}
-            //     .blacklist=${this._genBlacklist(this._forms[id]?.document)}
-            //   ></generated-form>
-            // `;
-          });
-      }
-    }
-
-    return html`
-      <x-flex space="sm">
-        <x-text tone="subtle"> ${path.join(' > ')} </x-text>
-        <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}>
-          ${JSON.stringify(data, null, 2)}
-        </x-text>
-      </x-flex>
-    `;
-  }
-
-  private _nodeContent(node: IntrospectNode, parent: boolean, path: string[] = []) {
-    const customComp = node.meta['component'];
-    if (customComp) {
-      return htmlStatic`<x-flex padding="sm" space="sm" ${css(cellBorder)}>
-        <${unsafeStatic(customComp)} .value=${node}></${unsafeStatic(customComp)}>
-      </x-flex>`;
-    }
-
-    const startedAt = node.startedAt ? new Date(node.startedAt).toLocaleString() : 'No Start Time';
-    const finishedAt = node.finishedAt ? new Date(node.finishedAt).toLocaleString() : 'No Finish Time';
-    const elapsed = node.startedAt && node.finishedAt ? node.finishedAt.getTime() - node.startedAt.getTime() : 0;
-
-    return html` <x-flex padding="sm" space="sm" ${css(cellBorder)}>
-        <x-text ${css({ padding: this.spacing.xs, background: this.colors.accent.shade(50) })}>Arguments</x-text>
-        ${this._nodeDataContent(node.args, path)}
-      </x-flex>
-      <x-flex padding="sm" space="sm" ${css(cellBorder)}>
-        <x-text ${css({ padding: this.spacing.xs, background: this.colors.accent.shade(50) })}>Result</x-text>
-        ${this._nodeDataContent(node.result, path)}
-      </x-flex>
-      <x-flex padding="sm" space="sm" ${css(cellBorder)}>
-        <x-text ${css({ padding: this.spacing.xs, background: this.colors.accent.shade(50) })}>Errors</x-text>
-        <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}
-          >${JSON.stringify(node.errors, null, 2)}</x-text
-        >
-      </x-flex>
-      <x-flex padding="sm" space="sm" ${css(cellBorder)}>
-        <x-text ${css({ padding: this.spacing.xs, background: this.colors.accent.shade(50) })}>Meta</x-text>
-        <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}
-          >${JSON.stringify(node.meta, null, 2)}</x-text
-        >
-      </x-flex>
-      <x-flex
-        ${css({
-          ...cellBorder,
-          flexGrow: 1,
-        })}
-      >
-        <x-grid columns="1fr 1fr 1fr" padding="sm" space="sm">
-          <x-text>Started</x-text>
-          <x-text>Finished</x-text>
-          <x-text>Elapsed</x-text>
-          <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}>${startedAt}</x-text>
-          <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}>${finishedAt}</x-text>
-          <x-text ${css({ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' })}>${elapsed}ms</x-text>
-        </x-grid>
-      </x-flex>`;
-  }
-
-  private _node(node: IntrospectNode, parent: boolean, path: string[] = []) {
-    const closed = !!this._closed[path.join('.')];
-
+  private _nodeView(
+    node: { value: IntrospectNode | null; loading: boolean; error: Error | null },
+    parent: boolean,
+    path: string[] = [],
+  ) {
     return html`
       <x-flex ${css({ paddingBottom: parent ? '50px' : '0' })} horizontal>
-        <x-flex
-          ${css({
-            display: 'inline-flex',
-            background: 'white',
-            minWidth: '250px',
-            maxWidth: '450px',
-            flexShrink: '0',
-            borderRadius: this.sizes.borderRadiusSecondary,
-          })}
-        >
-          <x-flex
-            padding="sm"
-            space-between
-            horizontal
-            crossaxis-center
-            ${css({
-              background: this.colors.accent,
-              color: 'white',
-              borderRadius: `${this.sizes.borderRadiusSecondary} ${this.sizes.borderRadiusSecondary} 0 0`,
-            })}
-            ${acss({
-              cursor: 'pointer',
-              '&:hover': {
-                opacity: 0.5,
-              },
-            })}
-            @click=${() => {
-              this._closed[path.join('.')] = !this._closed[path.join('.')];
-              if (this._closed[path.join('.')]) {
-                // close of child nodes
-
-                this._closeAllChildNodes(node, path);
-              }
-              this.requestUpdate();
-            }}
-          >
-            <x-text>${node.operation}</x-text>
-            <x-icon> ${closed ? 'unfold_more' : 'unfold_less'} </x-icon>
-          </x-flex>
-          ${closed
-            ? html`<x-flex ${css({ border: '1px solid rgba(0,0,0,0.1)', flexGrow: 1 })} padding="sm">
-                <x-text ${css({ opacity: 0.5 })}>Expand to see more...</x-text>
-              </x-flex>`
-            : html` ${this._nodeContent(node, parent, path)} `}
-        </x-flex>
-
-        ${(node.children ?? []).length > 0
+        ${IntrospectViewerNode(node)}
+        ${(node.value?.childIDs ?? []).length > 0
           ? html`
               <x-flex ${css({ padding: `${this.spacing.sm} 0` })} space="md">
-                ${repeat(node.children ?? [], (child, index) => {
-                  return html`<x-flex horizontal>
-                    <x-flex space-between ${css({ padding: `${this.spacing.sm} ${this.spacing.md}` })}>
-                      <svg
-                        aria-hidden="true"
-                        xmlns="http://www.w3.org/2000/svg"
-                        width="24"
-                        height="24"
-                        fill="none"
-                        viewBox="0 0 24 24"
-                      >
-                        <path
-                          stroke="currentColor"
-                          stroke-linecap="round"
-                          stroke-linejoin="round"
-                          stroke-width="2"
-                          d="M19 12H5m14 0-4 4m4-4-4-4"
-                        />
-                      </svg>
-
-                      <svg
-                        class="w-6 h-6 text-gray-800 dark:text-white"
-                        aria-hidden="true"
-                        xmlns="http://www.w3.org/2000/svg"
-                        width="24"
-                        height="24"
-                        fill="none"
-                        viewBox="0 0 24 24"
-                      >
-                        <path
-                          stroke="currentColor"
-                          stroke-linecap="round"
-                          stroke-linejoin="round"
-                          stroke-width="2"
-                          d="M5 12h14M5 12l4-4m-4 4 4 4"
-                        />
-                      </svg>
-                    </x-flex>
-                    ${this._node(child, false, [...path, index.toString()])}
-                  </x-flex>`;
-                })}
+                ${repeat(
+                  node.value?.childIDs ?? [],
+                  (childID, index) =>
+                    html`
+                      ${stateful(() => {
+                        const introspectionNode = useQuery(
+                          childID ? getIntrospectNodeByID(childID)(selectionSet(() => IntrospectNode, 1)) : of(null),
+                          [childID],
+                        );
+
+                        return html`
+                          <x-flex horizontal>
+                            <x-flex space-between ${css({ padding: `${this.spacing.sm} ${this.spacing.md}` })}>
+                              <svg
+                                aria-hidden="true"
+                                xmlns="http://www.w3.org/2000/svg"
+                                width="24"
+                                height="24"
+                                fill="none"
+                                viewBox="0 0 24 24"
+                              >
+                                <path
+                                  stroke="currentColor"
+                                  stroke-linecap="round"
+                                  stroke-linejoin="round"
+                                  stroke-width="2"
+                                  d="M19 12H5m14 0-4 4m4-4-4-4"
+                                />
+                              </svg>
+
+                              <svg
+                                class="w-6 h-6 text-gray-800 dark:text-white"
+                                aria-hidden="true"
+                                xmlns="http://www.w3.org/2000/svg"
+                                width="24"
+                                height="24"
+                                fill="none"
+                                viewBox="0 0 24 24"
+                              >
+                                <path
+                                  stroke="currentColor"
+                                  stroke-linecap="round"
+                                  stroke-linejoin="round"
+                                  stroke-width="2"
+                                  d="M5 12h14M5 12l4-4m-4 4 4 4"
+                                />
+                              </svg>
+                            </x-flex>
+
+                            ${this._nodeView(introspectionNode, false, [...path, index.toString()])}
+                          </x-flex>
+                        `;
+                      })()}
+                    `,
+                )}
               </x-flex>
             `
           : nothing}
@@ -344,7 +161,8 @@ export class XIntrospectViewer extends ChemistryLitElement {
   }
 
   override render() {
-    if (!this.node) {
+    if (this._node.isLoading) return html` <x-loading-state no-wrapper></x-loading-state> `;
+    if (!this._node.value) {
       return html`
         <x-empty-placeholder no-wrapper .text=${'Keine Daten gefunden'} .img=${'close'}></x-empty-placeholder>
       `;
@@ -377,17 +195,19 @@ export class XIntrospectViewer extends ChemistryLitElement {
           }}
           ${css({ display: 'block', overflow: 'auto' })}
         >
-          ${this._node(this.node, true, ['root'])}</x-flex
+          ${this._nodeView({ value: this._node.value, loading: this._node.isLoading, error: null }, true, [
+            'root',
+          ])}</x-flex
         >
       </x-flex>
     `;
   }
 
-  private _closeAllChildNodes(node: IntrospectNode | undefined, path: string[]) {
-    if (!node) return;
-    node.children?.forEach((child, index) => {
-      this._closed[path.join('.') + '.' + index] = true;
-      this._closeAllChildNodes(child, [...path, index.toString()]);
-    });
-  }
+  // private _closeAllChildNodes(node: IntrospectNode | undefined, path: string[]) {
+  //   if (!node) return;
+  //   node.children?.forEach((child, index) => {
+  //     this._closed[path.join('.') + '.' + index] = true;
+  //     this._closeAllChildNodes(child, [...path, index.toString()]);
+  //   });
+  // }
 }
diff --git a/lab/introspect/entities/introspect-node.entity.ts b/lab/introspect/entities/introspect-node.entity.ts
new file mode 100644
index 0000000000..6e09c91ea0
--- /dev/null
+++ b/lab/introspect/entities/introspect-node.entity.ts
@@ -0,0 +1,88 @@
+import { A } from '@adornis/base/env-info.js';
+import type { Maybe } from '@adornis/base/utilTypes.js';
+import { ID } from '@adornis/baseql/baseqlTypes.js';
+import { Entity, Field } from '@adornis/baseql/decorators.js';
+import { AdornisEntity } from '@adornis/baseql/entities/adornisEntity.js';
+import { getRawCollection } from '@adornis/baseql/server/collections.js';
+import { getIntrospectCollectionName } from '../server/introspection-options.js';
+
+if (A.isServer) {
+  const collectionName = getIntrospectCollectionName();
+  const collection = await getRawCollection<any>(collectionName);
+
+  await collection.createIndex({
+    operation: 1,
+  });
+  await collection.createIndex({
+    operation: 1,
+    startedAt: -1,
+  });
+}
+
+/** Helper function to stringify a value if it's not already a string */
+function stringifyIfNeeded(value: unknown): string {
+  return JSON.stringify(value);
+}
+
+@Entity()
+export class IntrospectNode extends AdornisEntity {
+  static override _class = 'IntrospectNode';
+
+  @Field(() => ID)
+  _id!: string;
+
+  /** The name of the operation */
+  @Field(() => String)
+  operation: Maybe<string>;
+
+  /** The time the operation started */
+  @Field(() => Date)
+  startedAt: Maybe<Date>;
+
+  /** The time the operation finished */
+  @Field(() => Date)
+  finishedAt: Maybe<Date>;
+
+  /** The result of the operation */
+  @Field(() => String, {
+    resolve: function (this: IntrospectNode) {
+      return () => stringifyIfNeeded(this.result);
+    },
+  })
+  result: Maybe<string>;
+
+  /** Any errors of the operation */
+  @Field(() => String, {
+    resolve: function (this: IntrospectNode) {
+      return () => stringifyIfNeeded(this.errors);
+    },
+  })
+  errors: Maybe<string>;
+
+  /** The input arguments of the operation */
+  @Field(() => String, {
+    resolve: function (this: IntrospectNode) {
+      return () => stringifyIfNeeded(this.args);
+    },
+  })
+  args: Maybe<string>;
+
+  /** Session ID of who started the operation */
+  @Field(() => String)
+  sessionID: Maybe<string>;
+
+  /** User ID of who started the operation */
+  @Field(() => String)
+  userID: Maybe<string>;
+
+  @Field(() => [ID])
+  childIDs: Maybe<string[]>;
+
+  /** Any additional meta data */
+  @Field(() => String, {
+    resolve: function (this: IntrospectNode) {
+      return () => stringifyIfNeeded(this.meta);
+    },
+  })
+  meta: Maybe<string>;
+}
diff --git a/lab/introspect/introspect-node.ts b/lab/introspect/introspect-node.ts
index 10aedbc5a6..66b97d6edb 100644
--- a/lab/introspect/introspect-node.ts
+++ b/lab/introspect/introspect-node.ts
@@ -1,45 +1,66 @@
-export interface IntrospectNode {
-  /** Reference to the parent */
-  parent?: IntrospectNode;
+import { A } from '@adornis/base/env-info.js';
+import { IntrospectNode as IntrospectNodeEntity } from './entities/introspect-node.entity.js';
 
-  /** The name of the operation */
+export interface IIntrospectNode {
   operation: string;
-
-  /** The time the operation started */
-  startedAt?: Date;
-
-  /** The time the operation finished */
+  startedAt: Date;
   finishedAt?: Date;
-
-  /** The result of the operation */
   result?: any;
-
-  /** Any errors of the operation */
   errors?: any;
-
-  /** The input arguments of the operation */
   args?: Record<string, any>;
+  sessionID: string | null;
+  userID: string | null;
+  meta: Record<string, any>;
+}
 
-  /** Session ID of who started the operation */
-  sessionID?: string;
-
-  /** User ID of who started the operation */
-  userID?: string;
-
+export interface IIntrospectNodeWithChildren extends IIntrospectNode {
   /** Any operations that were called inside the operation */
-  children?: IntrospectNode[];
+  children?: IIntrospectNodeWithChildren[];
+}
 
-  /** Any additional meta data */
-  meta: Record<string, any>;
+export interface IIntrospectDBNode extends IIntrospectNodeWithChildren {
+  _id: string;
+  _class: string;
+  childIDs?: string[];
 }
 
 /**
- * Removes all parent references to avoid circular references
- * @param node The node to clean
- * @returns The cleaned node
+ * Converts an IIntrospectNode object into IntrospectNode entity with _id references
+ * @param node The node to convert
+ * @returns The converted entity and a map of all created entities
  */
-export function cleanParents(node: IntrospectNode) {
-  delete node.parent;
-  node.children = node.children?.map(child => cleanParents(child));
-  return node;
+export function convertIntrospectNodeObjectToDBObject(node: IIntrospectNodeWithChildren): {
+  rootEntity: IIntrospectDBNode;
+  allEntities: IIntrospectDBNode[];
+} {
+  const allEntities = new Map<string, IIntrospectDBNode>();
+
+  function convertNode(node: IIntrospectNodeWithChildren): IIntrospectDBNode {
+    const entity: IIntrospectDBNode = {
+      _id: A.getGloballyUniqueID(),
+      _class: IntrospectNodeEntity._class,
+      operation: node.operation,
+      startedAt: node.startedAt,
+      finishedAt: node.finishedAt,
+      result: node.result ?? null,
+      errors: node.errors ?? null,
+      args: node.args,
+      sessionID: node.sessionID,
+      userID: node.userID,
+      meta: node.meta,
+      childIDs: [],
+    };
+
+    allEntities.set(entity._id, entity);
+
+    if (node.children?.length) {
+      const childEntities = node.children.map(child => convertNode(child));
+      entity.childIDs = childEntities.map(child => child._id);
+    }
+
+    return entity;
+  }
+
+  const rootEntity = convertNode(node);
+  return { rootEntity, allEntities: Array.from(allEntities.values()) };
 }
diff --git a/lab/introspect/server/introspect.ts b/lab/introspect/server/introspect.ts
index 74ccb7ec39..1aef213465 100644
--- a/lab/introspect/server/introspect.ts
+++ b/lab/introspect/server/introspect.ts
@@ -1,23 +1,10 @@
-import { AdornisEntity } from '@adornis/baseql/entities/adornisEntity.js';
 import { AsyncLocalStorage } from 'async_hooks';
-import type { IntrospectNode } from '../introspect-node.js';
+import type { IIntrospectNodeWithChildren } from '../introspect-node.js';
+import { trackOperation } from './track-operation.js';
 
-export const introspectAsyncStore = AsyncLocalStorage ? new AsyncLocalStorage<IntrospectNode>() : undefined;
-
-/**
- * Helper to strip some unnecessary data from the result
- * @param data The data to clean
- * @returns The cleaned data
- */
-export function cleanData(data: any) {
-  if (Array.isArray(data)) {
-    return data.map(item => cleanData(item));
-  }
-  if (data instanceof AdornisEntity) {
-    return data.toObject();
-  }
-  return data;
-}
+export const introspectAsyncStore = AsyncLocalStorage
+  ? new AsyncLocalStorage<IIntrospectNodeWithChildren>()
+  : undefined;
 
 /**
  * Helper to wrap any function and store introspection information
@@ -26,34 +13,8 @@ export function cleanData(data: any) {
  * @param args The arguments to the function
  */
 export function wrapIntrospect<TFn extends (...args: any[]) => any>(opName: string, fn: TFn) {
-  return async (...args: Parameters<TFn>): Promise<Awaited<ReturnType<TFn>>> => {
-    const store = introspectAsyncStore?.getStore();
-
-    // if we are not in an introspection session then just run the function
-    if (!introspectAsyncStore || !store) return fn(...args);
-
-    // otherwise we construct a node
-    const node: IntrospectNode = {
-      operation: opName,
-      startedAt: new Date(),
-      meta: {},
-      args: {
-        args,
-      },
-    };
-    node.parent = store;
-    if (!store.children) store.children = [];
-    store.children.push(node);
-
-    // run the original function
-    const res = await introspectAsyncStore?.run(node, () => fn(...args));
-
-    // store the result
-    node.result = cleanData(res);
-    node.finishedAt = new Date();
-
-    return res;
-  };
+  return async (...args: Parameters<TFn>): Promise<Awaited<ReturnType<TFn>>> =>
+    trackOperation(opName, fn, args) as Promise<Awaited<ReturnType<TFn>>>;
 }
 
 /**
diff --git a/lab/introspect/server/introspectInjector.ts b/lab/introspect/server/introspectInjector.ts
index 36ee4602aa..16b6e8bea1 100644
--- a/lab/introspect/server/introspectInjector.ts
+++ b/lab/introspect/server/introspectInjector.ts
@@ -1,30 +1,17 @@
-import { A } from '@adornis/base/env-info.js';
-import { logger } from '@adornis/base/logging.js';
-import type { Maybe, OptionalPromise } from '@adornis/base/utilTypes.js';
 import type { BaseQLFieldDefinition } from '@adornis/baseql/metadata/types.js';
 import type { BaseQLWebsocketMessageGQLStart } from '@adornis/baseql/protocol.js';
 import { PassthroughHandler, type RequestHandler } from '@adornis/baseql/requestHandler.js';
-import { getRawCollection } from '@adornis/baseql/server/collections.js';
-import { context } from '@adornis/baseql/server/context.js';
-import type { ExecutionResult, GraphQLError } from 'graphql';
-import type { ObjMap } from 'graphql/jsutils/ObjMap.js';
+import type { ExecutionResult } from 'graphql';
 import assert from 'node:assert';
-import { cleanParents, type IntrospectNode } from '../introspect-node.js';
-import { cleanData, introspectAsyncStore } from './introspect.js';
-
-interface IntrospectInjectorOptions {
-  operationFilter?: (op: BaseQLFieldDefinition) => boolean;
-  saveHandler?: (node: IntrospectNode) => Promise<void>;
-  onCollected?: (node: IntrospectNode) => OptionalPromise<void>;
-  collectionName?: string;
-}
+import { introspectAsyncStore } from './introspect.js';
+import { setIntrospectOptions, type IntrospectInjectorOptions } from './introspection-options.js';
+import { trackOperation } from './track-operation.js';
 
 export class IntrospectInjector extends PassthroughHandler<ExecutionResult> {
-  private _opts: Maybe<IntrospectInjectorOptions> = null;
-
   constructor(upstream: RequestHandler<ExecutionResult>, opts?: IntrospectInjectorOptions) {
     super(upstream);
-    this._opts = opts;
+
+    setIntrospectOptions(opts);
   }
 
   override subscription(op: BaseQLFieldDefinition, payload: BaseQLWebsocketMessageGQLStart['payload']) {
@@ -34,106 +21,37 @@ export class IntrospectInjector extends PassthroughHandler<ExecutionResult> {
 
   override async query(op: BaseQLFieldDefinition, payload: BaseQLWebsocketMessageGQLStart['payload']) {
     assert(introspectAsyncStore);
-    // this has to be casted because the typing of AsyncLocalStorage does not infer the return value out of the callback anymore
-    return introspectAsyncStore.run(this.getStore(op, payload), async () => {
-      const res = await this.upstream.query(op, payload);
-
-      // * we ignore internal operations
-      if (op.name.indexOf('__') === 0) return res as unknown as Promise<ExecutionResult>;
 
-      // * we check if the parent is allowed to be tracked, if so all children are also allowed
-      if (!introspectAsyncStore?.getStore()?.parent && this._opts?.operationFilter && !this._opts.operationFilter(op))
-        return res as unknown as Promise<ExecutionResult>;
+    let baseqlOperationReturnValue: ExecutionResult;
 
-      await this._track(op, payload, res.data, res.errors as Maybe<GraphQLError[]>);
+    await trackOperation(
+      op.name,
+      async () => {
+        baseqlOperationReturnValue = await this.upstream.query(op, payload);
+        if (baseqlOperationReturnValue.errors?.length) throw baseqlOperationReturnValue.errors;
+        return baseqlOperationReturnValue.data?.[op.name];
+      },
+      [payload.variables],
+    );
 
-      return res as unknown as Promise<ExecutionResult>;
-    });
+    // @ts-expect-error waiting on _track call makes sure thie variable is set properly
+    return baseqlOperationReturnValue;
   }
 
   override async mutation(op: BaseQLFieldDefinition, payload: BaseQLWebsocketMessageGQLStart['payload']) {
-    assert(introspectAsyncStore);
-    // this has to be casted because the typing of AsyncLocalStorage does not infer the return value out of the callback anymore
-    return introspectAsyncStore.run(this.getStore(op, payload), async () => {
-      const res = await this.upstream.mutation(op, payload);
-
-      // * we ignore internal operations
-      if (op.name.indexOf('__') === 0) return res as unknown as Promise<ExecutionResult>;
-
-      // * we check if the parent is allowed to be tracked, if so all children are also allowed
-      if (!introspectAsyncStore?.getStore()?.parent && this._opts?.operationFilter && !this._opts.operationFilter(op))
-        return res as unknown as Promise<ExecutionResult>;
-
-      await this._track(op, payload, res.data, res.errors as Maybe<GraphQLError[]>);
-
-      return res as unknown as Promise<ExecutionResult>;
-    });
-  }
-
-  private async _track(
-    op: BaseQLFieldDefinition,
-    payload: BaseQLWebsocketMessageGQLStart['payload'],
-    data: Maybe<ObjMap<unknown>>,
-    errors: Maybe<GraphQLError[]>,
-  ) {
-    const store = introspectAsyncStore?.getStore();
-    if (store) {
-      store.args = payload.variables;
-      store.result = cleanData(data?.[op.name]);
-      store.errors = errors;
-      store.finishedAt = new Date();
-    }
-
-    // * if no parent then we are back at the root
-    if (store && !store.parent) {
-      store.sessionID = context.sessionID;
-      if (!context.serverContext) {
-        store.userID = context.userID;
-      }
-
-      if (this._opts?.saveHandler) {
-        await this._opts.saveHandler(store);
-      } else {
-        const col = await getRawCollection(this._opts?.collectionName ?? 'introspect');
-
-        // * we don't really need to wait for the insert to finish. Just start it.
-        col
-          .insertOne({
-            _id: A.getGloballyUniqueID() as any,
-            ...cleanParents(store),
-          })
-          // use lambda function instead of just passing the function to avoid this context issues
-          .catch(err =>
-            logger.error({ err, store: cleanParents(store) }, 'There was an error inserting the introspection data'),
-          );
-      }
-
-      if (this._opts?.onCollected) {
-        this._opts.onCollected(store);
-      }
-    }
-  }
-
-  getStore(op: BaseQLFieldDefinition, payload: BaseQLWebsocketMessageGQLStart['payload']): IntrospectNode {
-    assert(introspectAsyncStore);
-
-    const node: IntrospectNode = {
-      operation: op.name ?? 'No Name',
-      startedAt: new Date(),
-      meta: {},
-    };
-
-    if (op.name == 'getEntityByID' && payload.variables?.entityClass) {
-      node.meta['entityClass'] = payload.variables?.entityClass;
-    }
-
-    const parent = introspectAsyncStore.getStore();
-    if (parent) {
-      parent.children ??= [];
-      parent.children.push(node);
-      node.parent = parent;
-    }
-
-    return node;
+    let baseqlOperationReturnValue: ExecutionResult;
+
+    await trackOperation(
+      op.name,
+      async () => {
+        baseqlOperationReturnValue = await this.upstream.query(op, payload);
+        if (baseqlOperationReturnValue.errors?.length) throw baseqlOperationReturnValue.errors;
+        return baseqlOperationReturnValue.data?.[op.name];
+      },
+      [payload.variables],
+    );
+
+    // @ts-expect-error waiting on _track call makes sure thie variable is set properly
+    return baseqlOperationReturnValue;
   }
 }
diff --git a/lab/introspect/server/introspection-options.ts b/lab/introspect/server/introspection-options.ts
new file mode 100644
index 0000000000..10eb161215
--- /dev/null
+++ b/lab/introspect/server/introspection-options.ts
@@ -0,0 +1,62 @@
+import { logger } from '@adornis/base/logging.js';
+import type { OptionalPromise } from '@adornis/base/utilTypes.js';
+import { getRawCollection } from '@adornis/baseql/server/collections.js';
+import { type IIntrospectNodeWithChildren, convertIntrospectNodeObjectToDBObject } from '../introspect-node.js';
+
+export interface IntrospectInjectorOptions {
+  operationFilter?: (op: { name?: string }) => boolean;
+  saveHandler?: (node: IIntrospectNodeWithChildren) => Promise<void>;
+  onCollected?: (node: IIntrospectNodeWithChildren) => OptionalPromise<void>;
+  collectionName?: string;
+}
+
+let introspectOptionsAreSet: boolean = false;
+let operationFilter: IntrospectInjectorOptions['operationFilter'];
+let saveHandler: IntrospectInjectorOptions['saveHandler'];
+let onCollected: IntrospectInjectorOptions['onCollected'];
+let collectionName: IntrospectInjectorOptions['collectionName'];
+
+export function setIntrospectOptions(opts?: IntrospectInjectorOptions) {
+  if (introspectOptionsAreSet) return;
+
+  operationFilter = opts?.operationFilter;
+  saveHandler = opts?.saveHandler;
+  onCollected = opts?.onCollected;
+  collectionName = opts?.collectionName;
+  introspectOptionsAreSet = true;
+}
+
+export function getIntrospectOperationFilter() {
+  return operationFilter;
+}
+
+export function getIntrospectSaveHandler() {
+  return (
+    saveHandler ??
+    (async (node: IIntrospectNodeWithChildren) => {
+      const collectionName = getIntrospectCollectionName();
+      const col = await getRawCollection<any>(collectionName);
+
+      const { rootEntity, allEntities } = convertIntrospectNodeObjectToDBObject(node);
+
+      // * we don't really need to wait for the insert to finish. Just start it.
+      col
+        .insertMany(allEntities)
+        // use lambda function instead of just passing the function to avoid this context issues
+        .catch(err =>
+          logger.error(
+            { err, operationName: rootEntity.operation },
+            'There was an error inserting the introspection data',
+          ),
+        );
+    })
+  );
+}
+
+export function getIntrospectOnCollected() {
+  return onCollected;
+}
+
+export function getIntrospectCollectionName() {
+  return collectionName ?? 'introspect2';
+}
diff --git a/lab/introspect/server/track-operation.ts b/lab/introspect/server/track-operation.ts
new file mode 100644
index 0000000000..bdc797a920
--- /dev/null
+++ b/lab/introspect/server/track-operation.ts
@@ -0,0 +1,88 @@
+import { AdornisEntity } from '@adornis/baseql/entities/adornisEntity.js';
+import { context } from '@adornis/baseql/server/context.js';
+import assert from 'node:assert';
+import type { IIntrospectNodeWithChildren } from '../introspect-node.js';
+import { introspectAsyncStore } from './introspect.js';
+import {
+  getIntrospectOnCollected,
+  getIntrospectOperationFilter,
+  getIntrospectSaveHandler,
+} from './introspection-options.js';
+
+export async function trackOperation(operationName: string, op: (...args: any[]) => any, variables: any[] | undefined) {
+  // if operation should not be tracked dont spawn a new async context
+  const operationFilter = getIntrospectOperationFilter();
+  if (!introspectAsyncStore?.getStore() && operationFilter && !operationFilter({ name: operationName }))
+    return op(...(variables ?? []));
+
+  // spawn new async context and run function
+  assert(introspectAsyncStore);
+  return introspectAsyncStore.run(getStore({ name: operationName }), async () => {
+    let data: any;
+    let errors: Error[] | undefined;
+
+    try {
+      data = await op(...(variables ?? []));
+    } catch (err) {
+      errors = Array.isArray(err) ? err : [err];
+    }
+
+    const store = introspectAsyncStore?.getStore();
+    if (store) {
+      store.args = variables;
+      store.result = cleanData(data);
+      store.errors = errors;
+      store.finishedAt = new Date();
+    }
+
+    // * if no parent then we are back at the root
+    if (store && !store.parent) {
+      store.sessionID = context.sessionID;
+      if (!context.serverContext) {
+        store.userID = context.userID;
+      }
+
+      await getIntrospectSaveHandler()(store);
+
+      getIntrospectOnCollected()?.(store);
+    }
+
+    return data;
+  });
+}
+
+function getStore(op: { name?: string }): IIntrospectNodeWithChildren {
+  assert(introspectAsyncStore);
+
+  const node: IIntrospectNodeWithChildren = {
+    operation: op.name ?? 'No Name',
+    startedAt: new Date(),
+    meta: {},
+    // @ts-expect-error could be a user context
+    userID: context.userID,
+    sessionID: context.sessionID,
+  };
+
+  const parent = introspectAsyncStore.getStore();
+  if (parent) {
+    parent.children ??= [];
+    parent.children.push(node);
+  }
+
+  return node;
+}
+
+/**
+ * Helper to strip some unnecessary data from the result
+ * @param data The data to clean
+ * @returns The cleaned data
+ */
+function cleanData(data: any) {
+  if (Array.isArray(data)) {
+    return data.map(item => cleanData(item));
+  }
+  if (data instanceof AdornisEntity) {
+    return data.toObject();
+  }
+  return data;
+}
diff --git a/lab/introspect/startup.test.ts b/lab/introspect/startup.test.ts
new file mode 100644
index 0000000000..784bfa16db
--- /dev/null
+++ b/lab/introspect/startup.test.ts
@@ -0,0 +1,12 @@
+// TODO
+
+/**
+ * query/mutation entrypoint no sub methods
+ * query/mutation entrypoint with sub methods
+ * query/mutation entrypoint with tracked sub methods
+ * query/mutation entrypoint with sub querys/mutations
+ * tracked method entrypoint with no sub methods
+ * tracked method entrypoint with sub methods
+ * tracked method entrypoint with tracked sub methods
+ * tracked method entrypoint with sub querys/mutations
+ */
-- 
GitLab


From 7eedd78581e4199be78e114c4fb27e43321bcf18 Mon Sep 17 00:00:00 2001
From: racct <elias@adornis.de>
Date: Fri, 14 Feb 2025 10:17:36 +0000
Subject: [PATCH 2/2] chore: fix typing issues

---
 lab/introspect/client/x-introspect-viewer-node.ts | 7 +++++--
 lab/introspect/introspect-node.ts                 | 1 +
 lab/introspect/package.json                       | 2 ++
 pnpm-lock.yaml                                    | 6 ++++++
 4 files changed, 14 insertions(+), 2 deletions(-)

diff --git a/lab/introspect/client/x-introspect-viewer-node.ts b/lab/introspect/client/x-introspect-viewer-node.ts
index c87df27daa..cffe305ffd 100644
--- a/lab/introspect/client/x-introspect-viewer-node.ts
+++ b/lab/introspect/client/x-introspect-viewer-node.ts
@@ -115,12 +115,15 @@ export const IntrospectViewerNode = stateful(
           case 'object':
             return stateful(() => {
               const entity = fillEmptyFields(constructValue(data));
-              const { form } = useGeneratedForm(entity, {
+              const form = useGeneratedForm(entity, {
                 disabledFields: entity ? selectionSet(() => entity.constructor as typeof AdornisEntity, 1) : {},
               });
               return html`
                 <x-text tone="subtle"> ${data._class} </x-text>
-                ${GeneratedForm({ form, blacklist: genBlacklist(form.document) })}
+                ${
+                  // @ts-expect-error useGeneratedForm needs correct typing
+                  GeneratedForm({ form, blacklist: genBlacklist(form.document) })
+                }
               `;
             })();
           case 'values':
diff --git a/lab/introspect/introspect-node.ts b/lab/introspect/introspect-node.ts
index 66b97d6edb..3911a31058 100644
--- a/lab/introspect/introspect-node.ts
+++ b/lab/introspect/introspect-node.ts
@@ -16,6 +16,7 @@ export interface IIntrospectNode {
 export interface IIntrospectNodeWithChildren extends IIntrospectNode {
   /** Any operations that were called inside the operation */
   children?: IIntrospectNodeWithChildren[];
+  parent?: IIntrospectNodeWithChildren;
 }
 
 export interface IIntrospectDBNode extends IIntrospectNodeWithChildren {
diff --git a/lab/introspect/package.json b/lab/introspect/package.json
index d9464f63f1..3defdc2029 100644
--- a/lab/introspect/package.json
+++ b/lab/introspect/package.json
@@ -15,6 +15,8 @@
     "@adornis/chemistry": "workspace:^",
     "@adornis/cms": "workspace:^",
     "@adornis/forms": "workspace:^",
+    "@adornis/functional-lit": "workspace:^",
+    "@adornis/generated-form": "workspace:^",
     "@adornis/users": "workspace:^",
     "graphql": "^16.8.0",
     "lit": "^3.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 46cfb37b72..f66e2b0bbe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -776,6 +776,12 @@ importers:
       '@adornis/forms':
         specifier: workspace:^
         version: link:../../modules/forms
+      '@adornis/functional-lit':
+        specifier: workspace:^
+        version: link:../functional-lit
+      '@adornis/generated-form':
+        specifier: workspace:^
+        version: link:../../modules/generated-form
       '@adornis/users':
         specifier: workspace:^
         version: link:../../modules/users
-- 
GitLab