akanjs 3.1.2-rc.2 → 3.1.2-rc.3

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,57 @@
1
+ export type AkanGraphNodeKind =
2
+ | "model"
3
+ | "scalar"
4
+ | "enum"
5
+ | "query"
6
+ | "slice"
7
+ | "endpoint"
8
+ | "guard"
9
+ | "method"
10
+ | "action"
11
+ | "component";
12
+
13
+ export type AkanGraphEdgeKind = "field" | "guard" | "calls" | "fetches" | "uses";
14
+
15
+ export interface AkanGraphLocation {
16
+ file: string;
17
+ line: number;
18
+ column: number;
19
+ }
20
+
21
+ export interface AkanGraphField {
22
+ name: string;
23
+ type: string | null;
24
+ array: boolean;
25
+ optional: boolean;
26
+ ref: string | null;
27
+ declaredIn: string;
28
+ at: AkanGraphLocation;
29
+ }
30
+
31
+ export interface AkanGraphNode {
32
+ id: string;
33
+ kind: AkanGraphNodeKind;
34
+ name: string;
35
+ label: string;
36
+ owner: string | null;
37
+ module: string | null;
38
+ detail: string[];
39
+ at: AkanGraphLocation | null;
40
+ fields?: AkanGraphField[];
41
+ values?: string[];
42
+ }
43
+
44
+ export interface AkanGraphEdge {
45
+ from: string;
46
+ to: string;
47
+ kind: AkanGraphEdgeKind;
48
+ label: string | null;
49
+ count: number;
50
+ at: AkanGraphLocation | null;
51
+ }
52
+
53
+ export interface AkanGraph {
54
+ version: 1;
55
+ nodes: AkanGraphNode[];
56
+ edges: AkanGraphEdge[];
57
+ }
package/common/index.ts CHANGED
@@ -1,3 +1,12 @@
1
+ export type {
2
+ AkanGraph,
3
+ AkanGraphEdge,
4
+ AkanGraphEdgeKind,
5
+ AkanGraphField,
6
+ AkanGraphLocation,
7
+ AkanGraphNode,
8
+ AkanGraphNodeKind,
9
+ } from "./akanGraph";
1
10
  export {
2
11
  databaseStateNames,
3
12
  endpointNames,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.2-rc.2",
3
+ "version": "3.1.2-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -9,7 +9,7 @@ import { getApiPrefix, getWsPrefix, normalizeRoutePrefix, resetEnvCache } from "
9
9
  import { CrossSiteGuard } from "../signal/CrossSiteGuard";
10
10
  import { isTraceEnabled } from "../signal/trace";
11
11
  import { AKAN_CHILD_HOST, makeAkanChildProxyHeaders } from "./akanAppHeaders";
12
- import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
12
+ import type { BuilderMessage, BuilderRequest, BuilderResponse } from "./artifact";
13
13
  import { type AssetHeaderOptions, applySecurityHeaders, assetHeaders } from "./assetHeaders";
14
14
  import { compressResponse, compressStreamResponse, encodedFileResponse } from "./contentEncoding";
15
15
  import { isPortInUseError } from "./lifecycle/portInUse";
@@ -1150,6 +1150,7 @@ export class AkanApp {
1150
1150
  return;
1151
1151
  case "build-route":
1152
1152
  case "build-csr":
1153
+ case "build-graph":
1153
1154
  this.#forwardBuilderReq(idx, message);
1154
1155
  return;
1155
1156
  }
@@ -1169,6 +1170,7 @@ export class AkanApp {
1169
1170
  return;
1170
1171
  case "build-route-res":
1171
1172
  case "build-csr-res":
1173
+ case "build-graph-res":
1172
1174
  this.#forwardBuilderRes(message);
1173
1175
  return;
1174
1176
  }
@@ -1354,13 +1356,13 @@ export class AkanApp {
1354
1356
  }
1355
1357
  }
1356
1358
 
1357
- #forwardBuilderReq(childIdx: number, message: BuilderReq | BuilderCsrReq) {
1359
+ #forwardBuilderReq(childIdx: number, message: BuilderRequest) {
1358
1360
  const gatewayReqId = this.#nextBuilderReqId++;
1359
1361
  this.#builderReqMap.set(gatewayReqId, { childIdx, childLocalId: message.id });
1360
- process.send?.({ ...message, id: gatewayReqId } satisfies BuilderReq | BuilderCsrReq);
1362
+ process.send?.({ ...message, id: gatewayReqId } satisfies BuilderRequest);
1361
1363
  }
1362
1364
 
1363
- #forwardBuilderRes(message: BuilderRes | BuilderCsrRes) {
1365
+ #forwardBuilderRes(message: BuilderResponse) {
1364
1366
  const request = this.#builderReqMap.get(message.id);
1365
1367
  if (!request) {
1366
1368
  this.logger.warn(`No child found for ${message.type} id=${message.id}`);
@@ -1369,7 +1371,7 @@ export class AkanApp {
1369
1371
  this.#builderReqMap.delete(message.id);
1370
1372
  const child = this.#children.get(request.childIdx);
1371
1373
  if (!child || child.proc.killed) return;
1372
- this.#sendToChild(child, { ...message, id: request.childLocalId } satisfies BuilderRes | BuilderCsrRes);
1374
+ this.#sendToChild(child, { ...message, id: request.childLocalId } satisfies BuilderResponse);
1373
1375
  }
1374
1376
 
1375
1377
  #fanoutToFederation(message: AkanIpcMessage | BuilderMessage, exceptIdx?: number) {
@@ -782,6 +782,7 @@ export class AkanServer {
782
782
  websocketPrefix: this.websocketPrefix,
783
783
  openapi: this.openapi,
784
784
  getStatus: () => this.status,
785
+ buildGraph: webRouter ? () => webRouter.buildGraph() : undefined,
785
786
  }).createRoutes();
786
787
  return { ...openapiRoutes, ...mcpRoutes, ...devtoolsRoutes, ...soloRoutes };
787
788
  }
@@ -1,11 +1,10 @@
1
1
  import { Logger } from "akanjs/common";
2
2
  import type {
3
- BuilderCsrReq,
4
- BuilderCsrRes,
5
3
  BuilderEvent,
6
4
  BuilderMessage,
7
- BuilderReq,
8
- BuilderRes,
5
+ BuilderRequest,
6
+ BuilderResponse,
7
+ BuildGraphPayload,
9
8
  BuildRouteClientResult,
10
9
  BuildRouteResultPayload,
11
10
  CsrUpdatedPayload,
@@ -31,7 +30,7 @@ export class BuilderRpc {
31
30
  readonly #logger = new Logger("BuilderRpc");
32
31
  readonly #pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
33
32
  readonly #offMessage: () => void;
34
- readonly #send: (msg: BuilderReq | BuilderCsrReq) => void;
33
+ readonly #send: (msg: BuilderRequest) => void;
35
34
  #nextId = 1;
36
35
  #disposed = false;
37
36
 
@@ -40,13 +39,13 @@ export class BuilderRpc {
40
39
  throw new Error("[builder] process.send unavailable — backend must be spawned by the CLI with ipc enabled");
41
40
  this.#send = process.send.bind(process);
42
41
  this.#offMessage = this.#listen((msg) => {
43
- if (msg.type === "build-route-res" || msg.type === "build-csr-res") {
44
- const res = msg as BuilderRes | BuilderCsrRes;
42
+ if (msg.type === "build-route-res" || msg.type === "build-csr-res" || msg.type === "build-graph-res") {
43
+ const res = msg as BuilderResponse;
45
44
  const waiter = this.#pending.get(res.id);
46
45
  if (!waiter) return;
47
46
  this.#pending.delete(res.id);
48
47
  if (!res.ok) waiter.reject(new Error(`[builder] ${res.type} failed: ${res.error}`));
49
- else waiter.resolve(res.type === "build-route-res" ? res.data : undefined);
48
+ else waiter.resolve(res.type === "build-csr-res" ? undefined : res.data);
50
49
  return;
51
50
  }
52
51
  const ev = msg as BuilderEvent;
@@ -114,6 +113,10 @@ export class BuilderRpc {
114
113
  );
115
114
  }
116
115
 
116
+ async buildGraph(): Promise<BuildGraphPayload> {
117
+ return await this.#request<BuildGraphPayload>("build-graph", (id) => this.#send({ type: "build-graph", id }));
118
+ }
119
+
117
120
  static #timeoutMs(): number {
118
121
  const configured = Number(process.env.AKAN_BUILDER_RPC_TIMEOUT_MS);
119
122
  if (Number.isFinite(configured) && configured > 0) return configured;
@@ -1,3 +1,4 @@
1
+ import type { AkanGraph } from "akanjs/common";
1
2
  import type { SsrManifest } from "../ssrTypes";
2
3
 
3
4
  export interface BuildRouteResultPayload {
@@ -80,6 +81,17 @@ export type BuilderCsrRes =
80
81
  | { type: "build-csr-res"; id: number; ok: true }
81
82
  | { type: "build-csr-res"; id: number; ok: false; error: string };
82
83
 
84
+ export interface BuildGraphPayload {
85
+ workspaceRoot: string;
86
+ graph: AkanGraph;
87
+ }
88
+
89
+ export type BuilderGraphReq = { type: "build-graph"; id: number };
90
+
91
+ export type BuilderGraphRes =
92
+ | { type: "build-graph-res"; id: number; ok: true; data: BuildGraphPayload }
93
+ | { type: "build-graph-res"; id: number; ok: false; error: string };
94
+
83
95
  /** Drain and exit (the only way bundler memory returns to the OS); draining, not killing, keeps a rebuild whole. */
84
96
  export type BuilderControl = { type: "builder-shutdown"; reason: string };
85
97
 
@@ -164,7 +176,11 @@ export type BuilderEvent =
164
176
  /** The boot builds settled (the SSR registry's, and CSR's when the env arms it): their workers are a boot's peak. */
165
177
  | { type: "boot-armed" };
166
178
 
167
- export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderControl | BuilderEvent;
179
+ export type BuilderRequest = BuilderReq | BuilderCsrReq | BuilderGraphReq;
180
+
181
+ export type BuilderResponse = BuilderRes | BuilderCsrRes | BuilderGraphRes;
182
+
183
+ export type BuilderMessage = BuilderRequest | BuilderResponse | BuilderControl | BuilderEvent;
168
184
 
169
185
  export interface ClientManifestEntry {
170
186
  id: string;
@@ -2,11 +2,13 @@ import type { BaseEnv } from "akanjs/base";
2
2
  import { Logger } from "akanjs/common";
3
3
  import { DictionaryRegistry, type RootDictionary } from "akanjs/dictionary";
4
4
  import { isLocalDevRuntime } from "../../signal/trace";
5
+ import type { BuildGraphPayload } from "../artifact";
5
6
  import type { DiLifecycle } from "../di/diLifecycle";
6
7
  import type { HttpRoutes } from "../types";
7
8
  import { ConstantSerializer } from "./constantSerializer";
8
9
  import { DepsSerializer } from "./depsSerializer";
9
10
  import { SignalSerializer } from "./signalSerializer";
11
+ import { StudioRoute } from "./studio/studioRoute";
10
12
  import type { DevtoolsApp, DevtoolsEnvelope, DevtoolsIndex, DictionaryData } from "./types";
11
13
 
12
14
  export interface DevtoolsRouterContext {
@@ -18,6 +20,7 @@ export interface DevtoolsRouterContext {
18
20
  websocketPrefix: string;
19
21
  openapi: boolean;
20
22
  getStatus: () => string;
23
+ buildGraph?: () => Promise<BuildGraphPayload | null>;
21
24
  }
22
25
 
23
26
  /** Builtin routes, not signals: off `/api` and guards, and never in the `serializedSignal` payload sent to clients. */
@@ -41,7 +44,14 @@ export class DevtoolsRouter {
41
44
 
42
45
  createRoutes(): HttpRoutes {
43
46
  if (!DevtoolsRouter.isEnabled(this.#context.env)) return {};
47
+ const studio = new StudioRoute({
48
+ basePath: `${DevtoolsRouter.basePath}/studio`,
49
+ appName: this.#context.env.appName,
50
+ buildGraph: this.#context.buildGraph ?? null,
51
+ });
44
52
  return {
53
+ [`${DevtoolsRouter.basePath}/studio`]: { GET: (req: Request) => this.#gated(() => studio.page(req)) },
54
+ [`${DevtoolsRouter.basePath}/studio/graph`]: { GET: () => this.#gated(() => studio.graph()) },
45
55
  [`${DevtoolsRouter.basePath}/devtools`]: { GET: () => DevtoolsRouter.#json(this.#index()) },
46
56
  [`${DevtoolsRouter.basePath}/constant`]: {
47
57
  GET: () => this.#respond("constant", () => ConstantSerializer.serialize()),
@@ -100,6 +110,11 @@ export class DevtoolsRouter {
100
110
  }
101
111
  }
102
112
 
113
+ async #gated(respond: () => Promise<Response>): Promise<Response> {
114
+ if (!DevtoolsRouter.isEnabled(this.#context.env)) return DevtoolsRouter.#json({ error: "Not found" }, 404);
115
+ return await respond();
116
+ }
117
+
103
118
  #envelope<Kind extends string, Data>(kind: Kind, data: Data): DevtoolsEnvelope<Kind, Data> {
104
119
  return { kind, version: 1, generatedAt: new Date().toISOString(), app: this.#app(), data };
105
120
  }
@@ -0,0 +1,202 @@
1
+ import type { AkanGraphLocation } from "akanjs/common";
2
+ import type { BuildGraphPayload } from "../../artifact";
3
+ import { type StudioBox, StudioLayout, type StudioScene, type StudioView } from "./studioLayout";
4
+ import { studioRuntime } from "./studioRuntime";
5
+ import { studioStyle } from "./studioStyle";
6
+
7
+ export type StudioEditor = "vscode" | "cursor";
8
+
9
+ export interface StudioQuery {
10
+ view: StudioView;
11
+ module: string | null;
12
+ editor: StudioEditor;
13
+ }
14
+
15
+ export interface StudioDocumentProps {
16
+ basePath: string;
17
+ appName: string;
18
+ payload: BuildGraphPayload;
19
+ query: StudioQuery;
20
+ }
21
+
22
+ const editorLabels: { [editor in StudioEditor]: string } = { vscode: "VS Code", cursor: "Cursor" };
23
+ const viewLabels: { [view in StudioView]: string } = { erd: "ERD", signals: "Signals" };
24
+ const charWidth = 7.3;
25
+ const kindShownIn: { [view in StudioView]: Set<string> } = {
26
+ erd: new Set(["model", "scalar", "enum"]),
27
+ signals: new Set(["endpoint", "slice", "query", "model"]),
28
+ };
29
+ const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
30
+
31
+ export class StudioDocument {
32
+ static render({ basePath, appName, payload, query }: StudioDocumentProps) {
33
+ const modules = StudioLayout.modulesOf(payload.graph);
34
+ const scene =
35
+ query.view === "erd"
36
+ ? StudioLayout.erd(payload.graph, query.module)
37
+ : StudioLayout.signals(payload.graph, query.module ?? modules[0] ?? null);
38
+ const hrefOf = (next: Partial<StudioQuery>) => StudioDocument.href(basePath, { ...query, ...next });
39
+ const openOf = (at: AkanGraphLocation | null) =>
40
+ at ? StudioDocument.editorHref(query.editor, payload.workspaceRoot, at) : undefined;
41
+ return (
42
+ <html lang="en">
43
+ <head>
44
+ <meta charSet="utf-8" />
45
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
46
+ <title>{`Akan Studio · ${appName}`}</title>
47
+ <style>{studioStyle}</style>
48
+ </head>
49
+ <body>
50
+ <header>
51
+ <strong>Akan Studio</strong>
52
+ <span className="muted">{appName}</span>
53
+ <nav>
54
+ {(["erd", "signals"] as const).map((view) => (
55
+ <a key={view} href={hrefOf({ view })} aria-current={view === query.view ? "page" : undefined}>
56
+ {viewLabels[view]}
57
+ </a>
58
+ ))}
59
+ </nav>
60
+ <form method="get" action={basePath}>
61
+ <input type="hidden" name="view" value={query.view} />
62
+ <input type="hidden" name="editor" value={query.editor} />
63
+ <select name="module" defaultValue={query.module ?? ""} data-studio-module="">
64
+ {query.view === "erd" ? <option value="">All modules</option> : null}
65
+ {modules.map((module) => (
66
+ <option key={module} value={module}>
67
+ {module}
68
+ </option>
69
+ ))}
70
+ </select>
71
+ <noscript>
72
+ <button type="submit">Show</button>
73
+ </noscript>
74
+ </form>
75
+ <input type="search" placeholder="Filter by name" data-studio-filter="" />
76
+ <span className="muted">{`${scene.boxes.length} nodes · ${scene.links.length} edges`}</span>
77
+ <nav className="end">
78
+ {(["vscode", "cursor"] as const).map((editor) => (
79
+ <a key={editor} href={hrefOf({ editor })} aria-current={editor === query.editor ? "true" : undefined}>
80
+ {editorLabels[editor]}
81
+ </a>
82
+ ))}
83
+ <a href={`${basePath}/graph`}>JSON</a>
84
+ </nav>
85
+ </header>
86
+ <main>
87
+ {scene.boxes.length ? (
88
+ StudioDocument.#svg(scene, query.view, openOf)
89
+ ) : (
90
+ <p className="empty">Nothing to draw here. Pick another module or view.</p>
91
+ )}
92
+ </main>
93
+ {StudioDocument.#runtime()}
94
+ </body>
95
+ </html>
96
+ );
97
+ }
98
+
99
+ static unavailable(appName: string, reason: string) {
100
+ return (
101
+ <html lang="en">
102
+ <head>
103
+ <meta charSet="utf-8" />
104
+ <title>{`Akan Studio · ${appName}`}</title>
105
+ <style>{studioStyle}</style>
106
+ </head>
107
+ <body>
108
+ <p className="empty">{reason}</p>
109
+ {StudioDocument.#runtime()}
110
+ </body>
111
+ </html>
112
+ );
113
+ }
114
+
115
+ static #runtime() {
116
+ return (
117
+
118
+ <script dangerouslySetInnerHTML={{ __html: `(${studioRuntime.toString()})();` }} />
119
+ );
120
+ }
121
+
122
+ static href(basePath: string, { view, module, editor }: StudioQuery): string {
123
+ const search = new URLSearchParams({ view, editor });
124
+ if (module) search.set("module", module);
125
+ return `${basePath}?${search.toString()}`;
126
+ }
127
+
128
+ static editorHref(editor: StudioEditor, workspaceRoot: string, at: AkanGraphLocation): string {
129
+ const root = workspaceRoot.replace(/\\/g, "/").replace(/\/$/, "");
130
+ const file = `${root.startsWith("/") ? "" : "/"}${root}/${at.file}`;
131
+ return `${editor}://file${encodeURI(file)}:${at.line}:${at.column}`;
132
+ }
133
+
134
+ static #svg(scene: StudioScene, view: StudioView, openOf: (at: AkanGraphLocation | null) => string | undefined) {
135
+ return (
136
+ <svg data-studio="" width={scene.width} height={scene.height} viewBox={`0 0 ${scene.width} ${scene.height}`}>
137
+ <defs>
138
+ <marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
139
+ <path d="M0,0 L8,4 L0,8 z" className="arrow" />
140
+ </marker>
141
+ </defs>
142
+ {scene.columns.map((column) =>
143
+ column.label ? (
144
+ <text key={column.x} className="column" x={column.x} y={24}>
145
+ {column.label}
146
+ </text>
147
+ ) : null,
148
+ )}
149
+ <g>
150
+ {scene.links.map(({ edge, path }) => (
151
+ <path
152
+ key={`${edge.from}>${edge.to}>${edge.kind}>${edge.label ?? ""}`}
153
+ d={path}
154
+ className={`link ${edge.kind}`}
155
+ data-from={edge.from}
156
+ data-to={edge.to}
157
+ markerEnd="url(#arrow)"
158
+ >
159
+ <title>{`${edge.kind} ${edge.label ?? ""}${edge.count > 1 ? ` ×${edge.count}` : ""}`}</title>
160
+ </path>
161
+ ))}
162
+ </g>
163
+ <g>{scene.boxes.map((box) => StudioDocument.#box(box, view, openOf))}</g>
164
+ </svg>
165
+ );
166
+ }
167
+
168
+ static #box(
169
+ { node, x, y, width, height, rows }: StudioBox,
170
+ view: StudioView,
171
+ openOf: (at: AkanGraphLocation | null) => string | undefined,
172
+ ) {
173
+ const lib = node.owner?.startsWith("libs/") ? node.owner.slice(5) : null;
174
+ const kind = [lib, kindShownIn[view].has(node.kind) ? node.kind : null].filter(Boolean).join(" · ");
175
+ const chars = Math.floor((width - 24) / charWidth);
176
+ return (
177
+ <g key={node.id} className={`box ${node.kind}`} data-node={node.id} data-label={node.label}>
178
+ <title>{node.at ? `${node.label}\n${node.at.file}:${node.at.line}` : node.label}</title>
179
+ <rect x={x} y={y} width={width} height={height} rx={6} />
180
+ <rect className="bar" x={x} y={y} width={4} height={height} rx={2} />
181
+ <a href={openOf(node.at)}>
182
+ <text className="title" x={x + 12} y={y + 18}>
183
+ {clip(node.label, kind ? chars - kind.length - 1 : chars)}
184
+ </text>
185
+ </a>
186
+ <text className="kind" x={x + width - 10} y={y + 18} textAnchor="end">
187
+ {kind}
188
+ </text>
189
+ {rows.map((row, idx) => (
190
+ <a key={idx} href={openOf(row.at)}>
191
+ <text x={x + 12} y={row.y + 4}>
192
+ {clip(row.text, row.hint ? chars - row.hint.length - 1 : chars)}
193
+ </text>
194
+ <text className="hint" x={x + width - 10} y={row.y + 4} textAnchor="end">
195
+ {row.hint}
196
+ </text>
197
+ </a>
198
+ ))}
199
+ </g>
200
+ );
201
+ }
202
+ }
@@ -0,0 +1,224 @@
1
+ import type { AkanGraph, AkanGraphEdge, AkanGraphLocation, AkanGraphNode, AkanGraphNodeKind } from "akanjs/common";
2
+
3
+ export type StudioView = "erd" | "signals";
4
+
5
+ export interface StudioRow {
6
+ text: string;
7
+ hint: string;
8
+ y: number;
9
+ at: AkanGraphLocation | null;
10
+ }
11
+
12
+ export interface StudioBox {
13
+ node: AkanGraphNode;
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ rows: StudioRow[];
19
+ }
20
+
21
+ export interface StudioLink {
22
+ edge: AkanGraphEdge;
23
+ path: string;
24
+ }
25
+
26
+ export interface StudioColumn {
27
+ label: string;
28
+ x: number;
29
+ }
30
+
31
+ export interface StudioScene {
32
+ boxes: StudioBox[];
33
+ links: StudioLink[];
34
+ columns: StudioColumn[];
35
+ width: number;
36
+ height: number;
37
+ }
38
+
39
+ const columnGap = 112;
40
+ const boxGap = 16;
41
+ const headerHeight = 28;
42
+ const rowHeight = 18;
43
+ const padding = 24;
44
+ const columnTop = 40;
45
+ const maxColumnHeight = 1800;
46
+ const typeKinds = new Set<AkanGraphNodeKind>(["model", "scalar", "enum"]);
47
+ const flowColumns: { label: string; kinds: AkanGraphNodeKind[] }[] = [
48
+ { label: "Components", kinds: ["component"] },
49
+ { label: "Store actions", kinds: ["action"] },
50
+ { label: "Endpoints & slices", kinds: ["endpoint", "slice"] },
51
+ { label: "Service methods", kinds: ["method"] },
52
+ { label: "Queries & models", kinds: ["query", "model"] },
53
+ ];
54
+ const flowColumnOf = new Map(flowColumns.flatMap(({ kinds }, idx) => kinds.map((kind) => [kind, idx] as const)));
55
+ const flowEdgeKinds = new Set(["calls", "fetches", "uses"]);
56
+
57
+ export class StudioLayout {
58
+ static modulesOf(graph: AkanGraph): string[] {
59
+ const modules = new Set<string>();
60
+
61
+ for (const node of graph.nodes)
62
+ if (node.module && node.kind !== "component" && flowColumnOf.has(node.kind)) modules.add(node.module);
63
+ return [...modules].sort();
64
+ }
65
+
66
+ static erd(graph: AkanGraph, module: string | null): StudioScene {
67
+ const types = graph.nodes.filter((node) => typeKinds.has(node.kind));
68
+ const fieldEdges = graph.edges.filter((edge) => edge.kind === "field");
69
+ const { nodes, edges } = StudioLayout.#around(types, fieldEdges, module);
70
+ const ranks = StudioLayout.#ranks(nodes, edges);
71
+ const groups = [...new Set(ranks.values())]
72
+ .sort((a, b) => a - b)
73
+ .map((rank) => nodes.filter((node) => ranks.get(node.id) === rank));
74
+ return StudioLayout.#place(groups, edges, { width: 248, labels: null, rowsOf: StudioLayout.#typeRows });
75
+ }
76
+
77
+ static signals(graph: AkanGraph, module: string | null): StudioScene {
78
+ const flowNodes = graph.nodes.filter((node) => flowColumnOf.has(node.kind));
79
+ const flowEdges = graph.edges.filter((edge) => flowEdgeKinds.has(edge.kind));
80
+ const { nodes, edges } = StudioLayout.#around(flowNodes, flowEdges, module);
81
+ const groups = flowColumns.map((_, idx) => nodes.filter((node) => flowColumnOf.get(node.kind) === idx));
82
+ return StudioLayout.#place(groups, edges, {
83
+ width: 264,
84
+ labels: flowColumns.map(({ label }) => label),
85
+ rowsOf: StudioLayout.#chipRows,
86
+ });
87
+ }
88
+
89
+ static #around(nodes: AkanGraphNode[], edges: AkanGraphEdge[], module: string | null) {
90
+ const byId = new Map(nodes.map((node) => [node.id, node]));
91
+ const linked = edges.filter((edge) => byId.has(edge.from) && byId.has(edge.to));
92
+ if (!module) return { nodes, edges: linked };
93
+ const seeds = new Set(nodes.filter((node) => node.module === module).map((node) => node.id));
94
+ const kept = linked.filter((edge) => seeds.has(edge.from) || seeds.has(edge.to));
95
+ const ids = new Set([...seeds, ...kept.flatMap((edge) => [edge.from, edge.to])]);
96
+ return { nodes: nodes.filter((node) => ids.has(node.id)), edges: kept };
97
+ }
98
+
99
+ static #ranks(nodes: AkanGraphNode[], edges: AkanGraphEdge[]) {
100
+ const out = new Map<string, string[]>();
101
+ for (const edge of edges) out.set(edge.from, [...(out.get(edge.from) ?? []), edge.to]);
102
+ const state = new Map<string, "open" | "done">();
103
+ const order: string[] = [];
104
+ const visit = (id: string) => {
105
+ if (state.has(id)) return;
106
+ state.set(id, "open");
107
+ for (const to of out.get(id) ?? []) if (state.get(to) !== "open") visit(to);
108
+ state.set(id, "done");
109
+ order.push(id);
110
+ };
111
+ for (const node of nodes) visit(node.id);
112
+ order.reverse();
113
+ const position = new Map(order.map((id, idx) => [id, idx]));
114
+ const ranks = new Map(nodes.map((node) => [node.id, 0]));
115
+ for (const id of order)
116
+ for (const to of out.get(id) ?? [])
117
+ if ((position.get(to) ?? 0) > (position.get(id) ?? 0))
118
+ ranks.set(to, Math.max(ranks.get(to) ?? 0, (ranks.get(id) ?? 0) + 1));
119
+ return ranks;
120
+ }
121
+
122
+ static #typeRows(node: AkanGraphNode): Omit<StudioRow, "y">[] {
123
+ if (node.values) return node.values.map((value) => ({ text: value, hint: "", at: null }));
124
+ return (node.fields ?? []).map((field) => ({
125
+ text: field.name,
126
+ hint: `${field.type ?? "?"}${field.array ? "[]" : ""}${field.optional ? "?" : ""}`,
127
+ at: field.at,
128
+ }));
129
+ }
130
+
131
+ static #chipRows(node: AkanGraphNode): Omit<StudioRow, "y">[] {
132
+ return node.detail.length ? [{ text: node.detail.join(" · "), hint: "", at: null }] : [];
133
+ }
134
+
135
+ static #place(
136
+ groups: AkanGraphNode[][],
137
+ edges: AkanGraphEdge[],
138
+ {
139
+ width,
140
+ labels,
141
+ rowsOf,
142
+ }: { width: number; labels: string[] | null; rowsOf: (node: AkanGraphNode) => Omit<StudioRow, "y">[] },
143
+ ): StudioScene {
144
+ const ordered = StudioLayout.#order(groups, edges);
145
+ const boxes: StudioBox[] = [];
146
+ const columns: StudioColumn[] = [];
147
+ let x = padding;
148
+ let height = 0;
149
+ ordered.forEach((group, idx) => {
150
+ if (labels && !group.length) return;
151
+ columns.push({ label: labels?.[idx] ?? "", x });
152
+ let y = columnTop;
153
+ for (const node of group) {
154
+ const rows = rowsOf(node);
155
+ const boxHeight = headerHeight + rows.length * rowHeight + (rows.length ? 6 : 0);
156
+ if (y > columnTop && y + boxHeight > maxColumnHeight) {
157
+ x += width + boxGap * 2;
158
+ y = columnTop;
159
+ }
160
+ const placed = rows.map((row, rowIdx) => ({
161
+ ...row,
162
+ y: y + headerHeight + rowIdx * rowHeight + rowHeight / 2,
163
+ }));
164
+ boxes.push({ node, x, y, width, height: boxHeight, rows: placed });
165
+ y += boxHeight + boxGap;
166
+ height = Math.max(height, y);
167
+ }
168
+ x += width + columnGap;
169
+ });
170
+ const boxOf = new Map(boxes.map((box) => [box.node.id, box]));
171
+ const links = edges.flatMap((edge) => {
172
+ const from = boxOf.get(edge.from);
173
+ const to = boxOf.get(edge.to);
174
+ return from && to ? [{ edge, path: StudioLayout.#path(from, to, edge) }] : [];
175
+ });
176
+ return { boxes, links, columns, width: x - columnGap + padding, height: height + padding };
177
+ }
178
+
179
+ static #order(groups: AkanGraphNode[][], edges: AkanGraphEdge[]): AkanGraphNode[][] {
180
+ const byLabel = (a: AkanGraphNode, b: AkanGraphNode) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
181
+ const ordered = groups.map((group) => [...group].sort(byLabel));
182
+ const neighbors = new Map<string, string[]>();
183
+ for (const { from, to } of edges) {
184
+ neighbors.set(from, [...(neighbors.get(from) ?? []), to]);
185
+ neighbors.set(to, [...(neighbors.get(to) ?? []), from]);
186
+ }
187
+ const sweep = (indices: number[]) => {
188
+ const position = new Map(ordered.flatMap((group) => group.map((node, idx) => [node.id, idx] as const)));
189
+ for (const idx of indices) {
190
+ const center = (node: AkanGraphNode) => {
191
+ const around = (neighbors.get(node.id) ?? []).flatMap((id) => {
192
+ const at = position.get(id);
193
+ return at === undefined ? [] : [at];
194
+ });
195
+ return around.length ? around.reduce((sum, at) => sum + at, 0) / around.length : (position.get(node.id) ?? 0);
196
+ };
197
+ const centers = new Map(ordered[idx].map((node) => [node.id, center(node)]));
198
+ ordered[idx].sort((a, b) => (centers.get(a.id) ?? 0) - (centers.get(b.id) ?? 0));
199
+ for (const [at, node] of ordered[idx].entries()) position.set(node.id, at);
200
+ }
201
+ };
202
+ const forward = ordered.map((_, idx) => idx).slice(1);
203
+ sweep(forward);
204
+ sweep([...forward].reverse().map((idx) => idx - 1));
205
+ return ordered;
206
+ }
207
+
208
+ static #path(from: StudioBox, to: StudioBox, edge: AkanGraphEdge): string {
209
+ const row =
210
+ edge.kind === "field"
211
+ ? from.rows.find(({ text }) => edge.label === text || edge.label === `${text}[]`)
212
+ : undefined;
213
+ const y1 = row?.y ?? from.y + headerHeight / 2;
214
+ const y2 = to.y + headerHeight / 2;
215
+ const x1 = from.x + from.width;
216
+ if (to.x > from.x + from.width) {
217
+ const bend = Math.max(40, (to.x - x1) / 2);
218
+ return `M${x1},${y1} C${x1 + bend},${y1} ${to.x - bend},${y2} ${to.x},${y2}`;
219
+ }
220
+ const x2 = to.x + to.width;
221
+ const loop = Math.max(x1, x2) + 48;
222
+ return `M${x1},${y1} C${loop},${y1} ${loop},${y2} ${x2},${y2}`;
223
+ }
224
+ }
@@ -0,0 +1,73 @@
1
+ import { Logger } from "akanjs/common";
2
+ import type { ReactNode } from "react";
3
+ import type { BuildGraphPayload } from "../../artifact";
4
+ import { createSystemPageHeaders } from "../../systemPages";
5
+ import { StudioDocument, type StudioEditor, type StudioQuery } from "./studioDocument";
6
+
7
+ export interface StudioRouteContext {
8
+ basePath: string;
9
+ appName: string;
10
+ buildGraph: (() => Promise<BuildGraphPayload | null>) | null;
11
+ }
12
+
13
+ const editors = new Set<StudioEditor>(["vscode", "cursor"]);
14
+
15
+ export class StudioRoute {
16
+ static readonly logger = new Logger("StudioRoute");
17
+ readonly #context: StudioRouteContext;
18
+
19
+ constructor(context: StudioRouteContext) {
20
+ this.#context = context;
21
+ }
22
+
23
+ static queryOf(url: URL): StudioQuery {
24
+ const view = url.searchParams.get("view") === "signals" ? "signals" : "erd";
25
+ const editor = url.searchParams.get("editor");
26
+ return {
27
+ view,
28
+ module: url.searchParams.get("module") || null,
29
+ editor: editor && editors.has(editor as StudioEditor) ? (editor as StudioEditor) : "vscode",
30
+ };
31
+ }
32
+
33
+ async page(req: Request): Promise<Response> {
34
+ const { basePath, appName } = this.#context;
35
+ const loaded = await this.#load();
36
+ if ("error" in loaded) return await StudioRoute.#html(StudioDocument.unavailable(appName, loaded.error), 503);
37
+ const query = StudioRoute.queryOf(new URL(req.url));
38
+ return await StudioRoute.#html(StudioDocument.render({ basePath, appName, payload: loaded.payload, query }), 200);
39
+ }
40
+
41
+ async graph(): Promise<Response> {
42
+ const loaded = await this.#load();
43
+ if ("error" in loaded)
44
+ return Response.json({ error: loaded.error }, { status: 503, headers: { "Cache-Control": "no-store" } });
45
+ return Response.json(loaded.payload, { headers: { "Cache-Control": "no-store" } });
46
+ }
47
+
48
+ async #load(): Promise<{ payload: BuildGraphPayload } | { error: string }> {
49
+ const { buildGraph } = this.#context;
50
+ try {
51
+ const payload = buildGraph ? await buildGraph() : null;
52
+ return payload
53
+ ? { payload }
54
+ : { error: "Akan Studio reads the graph from the dev builder; open it under `akan start`." };
55
+ } catch (error) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ StudioRoute.logger.warn(`Failed to build the Akan Graph: ${message}`);
58
+ return { error: message };
59
+ }
60
+ }
61
+
62
+ static #headers(): Headers {
63
+ const headers = createSystemPageHeaders();
64
+ headers.delete("X-Frame-Options");
65
+ headers.set("Content-Security-Policy", "frame-ancestors 'self' vscode-webview: vscode-file:; base-uri 'self'");
66
+ return headers;
67
+ }
68
+
69
+ static async #html(document: ReactNode, status: number): Promise<Response> {
70
+ const { renderToReadableStream } = await import("react-dom/server.browser");
71
+ return new Response(await renderToReadableStream(document), { status, headers: StudioRoute.#headers() });
72
+ }
73
+ }
@@ -0,0 +1,65 @@
1
+ export const studioRuntime = () => {
2
+ if (window.parent !== window) {
3
+ let host: string | null = null;
4
+ window.addEventListener("message", (event) => {
5
+ if (event.source !== window.parent || !event.origin.startsWith("vscode-webview:")) return;
6
+ if ((event.data as { type?: unknown } | null)?.type !== "akan-studio:host") return;
7
+ host = event.origin;
8
+ window.parent.postMessage({ type: "akan-studio:ready", href: location.href }, host);
9
+ });
10
+ document.addEventListener("click", (event) => {
11
+ const href = event.target instanceof Element ? event.target.closest("a[href]")?.getAttribute("href") : null;
12
+ const source = href?.match(/^[a-z-]+:\/\/file(\/.+):(\d+):(\d+)$/);
13
+ if (!host || !source) return;
14
+ event.preventDefault();
15
+ const [, path, line, column] = source;
16
+ window.parent.postMessage(
17
+ { type: "akan-studio:open", path: decodeURI(path), line: +line, column: +column },
18
+ host,
19
+ );
20
+ });
21
+ }
22
+ const select = document.querySelector<HTMLSelectElement>("select[data-studio-module]");
23
+ select?.addEventListener("change", () => select.form?.submit());
24
+ const svg = document.querySelector<SVGSVGElement>("svg[data-studio]");
25
+ if (!svg) return;
26
+ const links = [...svg.querySelectorAll<SVGPathElement>("path[data-from]")];
27
+ const boxes = new Map(
28
+ [...svg.querySelectorAll<SVGGElement>("g[data-node]")].map((box) => [box.dataset.node ?? "", box] as const),
29
+ );
30
+ let pinned: string | null = null;
31
+ const focus = (id: string | null) => {
32
+ svg.classList.toggle("focused", id !== null);
33
+ const near = new Set(id ? [id] : []);
34
+ for (const link of links) {
35
+ const isHot = id !== null && (link.dataset.from === id || link.dataset.to === id);
36
+ link.classList.toggle("hot", isHot);
37
+ if (isHot) for (const end of [link.dataset.from, link.dataset.to]) if (end) near.add(end);
38
+ }
39
+ for (const [key, box] of boxes) box.classList.toggle("hot", near.has(key));
40
+ };
41
+ for (const [id, box] of boxes) {
42
+ box.addEventListener("mouseenter", () => {
43
+ if (!pinned) focus(id);
44
+ });
45
+ box.addEventListener("mouseleave", () => {
46
+ if (!pinned) focus(null);
47
+ });
48
+ box.addEventListener("click", (event) => {
49
+ if (event.target instanceof Element && event.target.closest("a[href]")) return;
50
+ pinned = pinned === id ? null : id;
51
+ focus(pinned ?? id);
52
+ });
53
+ }
54
+ document.addEventListener("keydown", (event) => {
55
+ if (event.key !== "Escape") return;
56
+ pinned = null;
57
+ focus(null);
58
+ });
59
+ const filter = document.querySelector<HTMLInputElement>("input[data-studio-filter]");
60
+ filter?.addEventListener("input", () => {
61
+ const query = filter.value.trim().toLowerCase();
62
+ for (const box of boxes.values())
63
+ box.classList.toggle("muted", query !== "" && !(box.dataset.label ?? "").toLowerCase().includes(query));
64
+ });
65
+ };
@@ -0,0 +1,39 @@
1
+ export const studioStyle = `
2
+ :root { color-scheme: light dark; --bg: #f6f6f7; --fg: #1d1d1f; --muted: #6e6e73; --panel: #ffffff; --line: #c9c9ce;
3
+ --model: #2563eb; --scalar: #0d9488; --enum: #9333ea; --query: #64748b; --slice: #ea580c; --endpoint: #dc2626;
4
+ --method: #0891b2; --action: #16a34a; --component: #ca8a04; }
5
+ @media (prefers-color-scheme: dark) {
6
+ :root { --bg: #111113; --fg: #ececf1; --muted: #9a9aa3; --panel: #1c1c1f; --line: #3d3d44; }
7
+ }
8
+ body { margin: 0; font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--fg); }
9
+ header { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
10
+ padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
11
+ header nav { display: flex; gap: 4px; }
12
+ header nav.end { margin-left: auto; }
13
+ header a { color: inherit; text-decoration: none; padding: 4px 8px; border-radius: 6px; }
14
+ header a[aria-current] { background: var(--line); }
15
+ header select, header input { font: inherit; color: inherit; background: var(--bg); border: 1px solid var(--line);
16
+ border-radius: 6px; padding: 4px 8px; }
17
+ .muted { color: var(--muted); }
18
+ .empty { padding: 48px; color: var(--muted); }
19
+ main { overflow: auto; }
20
+ svg { display: block; }
21
+ svg text { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; fill: var(--fg); }
22
+ svg a { cursor: pointer; }
23
+ svg a:hover text { text-decoration: underline; }
24
+ .column { fill: var(--muted); font-weight: 600; letter-spacing: 0.04em; }
25
+ .box { cursor: default; }
26
+ .box rect { fill: var(--panel); stroke: var(--line); }
27
+ .box rect.bar { fill: currentColor; stroke: none; }
28
+ .box .title { font-weight: 600; }
29
+ .box .kind, .box .hint { fill: var(--muted); }
30
+ .box.model { color: var(--model); } .box.scalar { color: var(--scalar); } .box.enum { color: var(--enum); }
31
+ .box.query { color: var(--query); } .box.slice { color: var(--slice); } .box.endpoint { color: var(--endpoint); }
32
+ .box.method { color: var(--method); } .box.action { color: var(--action); } .box.component { color: var(--component); }
33
+ .link { fill: none; stroke: var(--line); stroke-width: 1.25; }
34
+ .link.fetches { stroke-dasharray: 5 3; }
35
+ .link.uses { stroke-dasharray: 2 3; }
36
+ .arrow { fill: var(--muted); }
37
+ svg.focused .box:not(.hot), svg.focused .link:not(.hot), .box.muted { opacity: 0.16; }
38
+ .link.hot { stroke: var(--fg); stroke-width: 2; }
39
+ `;
@@ -23,6 +23,7 @@ import type { PagePromptSource } from "../signal/mcp/pagePrompt";
23
23
  import { SignalContext } from "../signal/signalContext";
24
24
  import {
25
25
  type BuilderRpc,
26
+ type BuildGraphPayload,
26
27
  type MergedManifest,
27
28
  RouteClientCache,
28
29
  type RouteSeedIndex,
@@ -808,6 +809,9 @@ export class WebRouter {
808
809
  };
809
810
  return { renderEnvRoutes, hmrHub: this.#hub, builderRpc: this.#builderRpc };
810
811
  }
812
+ async buildGraph(): Promise<BuildGraphPayload | null> {
813
+ return this.#builderRpc ? await this.#builderRpc.buildGraph() : null;
814
+ }
811
815
  refreshHmrState(): void {
812
816
  this.#devHmr?.refreshRegistryState();
813
817
  }
@@ -0,0 +1,41 @@
1
+ export type AkanGraphNodeKind = "model" | "scalar" | "enum" | "query" | "slice" | "endpoint" | "guard" | "method" | "action" | "component";
2
+ export type AkanGraphEdgeKind = "field" | "guard" | "calls" | "fetches" | "uses";
3
+ export interface AkanGraphLocation {
4
+ file: string;
5
+ line: number;
6
+ column: number;
7
+ }
8
+ export interface AkanGraphField {
9
+ name: string;
10
+ type: string | null;
11
+ array: boolean;
12
+ optional: boolean;
13
+ ref: string | null;
14
+ declaredIn: string;
15
+ at: AkanGraphLocation;
16
+ }
17
+ export interface AkanGraphNode {
18
+ id: string;
19
+ kind: AkanGraphNodeKind;
20
+ name: string;
21
+ label: string;
22
+ owner: string | null;
23
+ module: string | null;
24
+ detail: string[];
25
+ at: AkanGraphLocation | null;
26
+ fields?: AkanGraphField[];
27
+ values?: string[];
28
+ }
29
+ export interface AkanGraphEdge {
30
+ from: string;
31
+ to: string;
32
+ kind: AkanGraphEdgeKind;
33
+ label: string | null;
34
+ count: number;
35
+ at: AkanGraphLocation | null;
36
+ }
37
+ export interface AkanGraph {
38
+ version: 1;
39
+ nodes: AkanGraphNode[];
40
+ edges: AkanGraphEdge[];
41
+ }
@@ -1,3 +1,4 @@
1
+ export type { AkanGraph, AkanGraphEdge, AkanGraphEdgeKind, AkanGraphField, AkanGraphLocation, AkanGraphNode, AkanGraphNodeKind, } from "./akanGraph.d.ts";
1
2
  export { databaseStateNames, endpointNames, formSetterNames, modelNames, moduleNames, queryNames, sliceNames, stateSetterName, } from "./akanNames.d.ts";
2
3
  export { AppHosts } from "./appHosts.d.ts";
3
4
  export { applyMixins, getAllPropertyDescriptors } from "./applyMixins.d.ts";
@@ -8,7 +8,7 @@ export declare class ConstantRegistry {
8
8
  static enum: Map<string, EnumInstance>;
9
9
  static value: Map<string, unknown>;
10
10
  static modelRefNameMap: Map<new (...args: never[]) => unknown, string>;
11
- static origin: Map<`database:${string}` | `scalar:${string}` | `enum:${string}`, string[]>;
11
+ static origin: Map<`scalar:${string}` | `enum:${string}` | `database:${string}`, string[]>;
12
12
  static getOrigin(kind: ConstantOriginKind, key: string): string[];
13
13
  static assertConsistentRefNames(skippedOrigins?: readonly string[]): void;
14
14
  static has(modelRef: Cls): boolean;
@@ -1,4 +1,4 @@
1
- import type { BuildRouteClientResult, CsrUpdatedPayload, CssPayload, DevBuildStatus, PagesBundlePayload, SsrUpdatedPayload } from "./ipcTypes.d.ts";
1
+ import type { BuildGraphPayload, BuildRouteClientResult, CsrUpdatedPayload, CssPayload, DevBuildStatus, PagesBundlePayload, SsrUpdatedPayload } from "./ipcTypes.d.ts";
2
2
  export interface BuilderRpcEventHandlers {
3
3
  /** A watcher batch changed files: clear the route cache and broadcast a dev reload. */
4
4
  onInvalidate?: (event: {
@@ -30,5 +30,6 @@ export declare class BuilderRpc {
30
30
  buildCsr(reason: string, { shell }?: {
31
31
  shell?: boolean;
32
32
  }): Promise<void>;
33
+ buildGraph(): Promise<BuildGraphPayload>;
33
34
  dispose(): void;
34
35
  }
@@ -1,3 +1,4 @@
1
+ import type { AkanGraph } from "akanjs/common";
1
2
  import type { SsrManifest } from "../ssrTypes.d.ts";
2
3
  export interface BuildRouteResultPayload {
3
4
  manifestDelta: Record<string, {
@@ -93,6 +94,25 @@ export type BuilderCsrRes = {
93
94
  ok: false;
94
95
  error: string;
95
96
  };
97
+ export interface BuildGraphPayload {
98
+ workspaceRoot: string;
99
+ graph: AkanGraph;
100
+ }
101
+ export type BuilderGraphReq = {
102
+ type: "build-graph";
103
+ id: number;
104
+ };
105
+ export type BuilderGraphRes = {
106
+ type: "build-graph-res";
107
+ id: number;
108
+ ok: true;
109
+ data: BuildGraphPayload;
110
+ } | {
111
+ type: "build-graph-res";
112
+ id: number;
113
+ ok: false;
114
+ error: string;
115
+ };
96
116
  /** Drain and exit (the only way bundler memory returns to the OS); draining, not killing, keeps a rebuild whole. */
97
117
  export type BuilderControl = {
98
118
  type: "builder-shutdown";
@@ -189,7 +209,9 @@ export type BuilderEvent = {
189
209
  | {
190
210
  type: "boot-armed";
191
211
  };
192
- export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderControl | BuilderEvent;
212
+ export type BuilderRequest = BuilderReq | BuilderCsrReq | BuilderGraphReq;
213
+ export type BuilderResponse = BuilderRes | BuilderCsrRes | BuilderGraphRes;
214
+ export type BuilderMessage = BuilderRequest | BuilderResponse | BuilderControl | BuilderEvent;
193
215
  export interface ClientManifestEntry {
194
216
  id: string;
195
217
  chunks: string[];
@@ -1,5 +1,6 @@
1
1
  import type { BaseEnv } from "akanjs/base";
2
2
  import { Logger } from "akanjs/common";
3
+ import type { BuildGraphPayload } from "../artifact.d.ts";
3
4
  import type { DiLifecycle } from "../di/diLifecycle.d.ts";
4
5
  import type { HttpRoutes } from "../types.d.ts";
5
6
  export interface DevtoolsRouterContext {
@@ -11,6 +12,7 @@ export interface DevtoolsRouterContext {
11
12
  websocketPrefix: string;
12
13
  openapi: boolean;
13
14
  getStatus: () => string;
15
+ buildGraph?: () => Promise<BuildGraphPayload | null>;
14
16
  }
15
17
  /** Builtin routes, not signals: off `/api` and guards, and never in the `serializedSignal` payload sent to clients. */
16
18
  export declare class DevtoolsRouter {
@@ -0,0 +1,22 @@
1
+ import type { AkanGraphLocation } from "akanjs/common";
2
+ import type { BuildGraphPayload } from "../../artifact.d.ts";
3
+ import { type StudioView } from "./studioLayout.d.ts";
4
+ export type StudioEditor = "vscode" | "cursor";
5
+ export interface StudioQuery {
6
+ view: StudioView;
7
+ module: string | null;
8
+ editor: StudioEditor;
9
+ }
10
+ export interface StudioDocumentProps {
11
+ basePath: string;
12
+ appName: string;
13
+ payload: BuildGraphPayload;
14
+ query: StudioQuery;
15
+ }
16
+ export declare class StudioDocument {
17
+ #private;
18
+ static render({ basePath, appName, payload, query }: StudioDocumentProps): import("react").JSX.Element;
19
+ static unavailable(appName: string, reason: string): import("react").JSX.Element;
20
+ static href(basePath: string, { view, module, editor }: StudioQuery): string;
21
+ static editorHref(editor: StudioEditor, workspaceRoot: string, at: AkanGraphLocation): string;
22
+ }
@@ -0,0 +1,37 @@
1
+ import type { AkanGraph, AkanGraphEdge, AkanGraphLocation, AkanGraphNode } from "akanjs/common";
2
+ export type StudioView = "erd" | "signals";
3
+ export interface StudioRow {
4
+ text: string;
5
+ hint: string;
6
+ y: number;
7
+ at: AkanGraphLocation | null;
8
+ }
9
+ export interface StudioBox {
10
+ node: AkanGraphNode;
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ rows: StudioRow[];
16
+ }
17
+ export interface StudioLink {
18
+ edge: AkanGraphEdge;
19
+ path: string;
20
+ }
21
+ export interface StudioColumn {
22
+ label: string;
23
+ x: number;
24
+ }
25
+ export interface StudioScene {
26
+ boxes: StudioBox[];
27
+ links: StudioLink[];
28
+ columns: StudioColumn[];
29
+ width: number;
30
+ height: number;
31
+ }
32
+ export declare class StudioLayout {
33
+ #private;
34
+ static modulesOf(graph: AkanGraph): string[];
35
+ static erd(graph: AkanGraph, module: string | null): StudioScene;
36
+ static signals(graph: AkanGraph, module: string | null): StudioScene;
37
+ }
@@ -0,0 +1,16 @@
1
+ import { Logger } from "akanjs/common";
2
+ import type { BuildGraphPayload } from "../../artifact.d.ts";
3
+ import { type StudioQuery } from "./studioDocument.d.ts";
4
+ export interface StudioRouteContext {
5
+ basePath: string;
6
+ appName: string;
7
+ buildGraph: (() => Promise<BuildGraphPayload | null>) | null;
8
+ }
9
+ export declare class StudioRoute {
10
+ #private;
11
+ static readonly logger: Logger;
12
+ constructor(context: StudioRouteContext);
13
+ static queryOf(url: URL): StudioQuery;
14
+ page(req: Request): Promise<Response>;
15
+ graph(): Promise<Response>;
16
+ }
@@ -0,0 +1 @@
1
+ export declare const studioRuntime: () => void;
@@ -0,0 +1 @@
1
+ export declare const studioStyle = "\n:root { color-scheme: light dark; --bg: #f6f6f7; --fg: #1d1d1f; --muted: #6e6e73; --panel: #ffffff; --line: #c9c9ce;\n --model: #2563eb; --scalar: #0d9488; --enum: #9333ea; --query: #64748b; --slice: #ea580c; --endpoint: #dc2626;\n --method: #0891b2; --action: #16a34a; --component: #ca8a04; }\n@media (prefers-color-scheme: dark) {\n :root { --bg: #111113; --fg: #ececf1; --muted: #9a9aa3; --panel: #1c1c1f; --line: #3d3d44; }\n}\nbody { margin: 0; font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--fg); }\nheader { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; gap: 12px; align-items: center;\n padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line); }\nheader nav { display: flex; gap: 4px; }\nheader nav.end { margin-left: auto; }\nheader a { color: inherit; text-decoration: none; padding: 4px 8px; border-radius: 6px; }\nheader a[aria-current] { background: var(--line); }\nheader select, header input { font: inherit; color: inherit; background: var(--bg); border: 1px solid var(--line);\n border-radius: 6px; padding: 4px 8px; }\n.muted { color: var(--muted); }\n.empty { padding: 48px; color: var(--muted); }\nmain { overflow: auto; }\nsvg { display: block; }\nsvg text { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; fill: var(--fg); }\nsvg a { cursor: pointer; }\nsvg a:hover text { text-decoration: underline; }\n.column { fill: var(--muted); font-weight: 600; letter-spacing: 0.04em; }\n.box { cursor: default; }\n.box rect { fill: var(--panel); stroke: var(--line); }\n.box rect.bar { fill: currentColor; stroke: none; }\n.box .title { font-weight: 600; }\n.box .kind, .box .hint { fill: var(--muted); }\n.box.model { color: var(--model); } .box.scalar { color: var(--scalar); } .box.enum { color: var(--enum); }\n.box.query { color: var(--query); } .box.slice { color: var(--slice); } .box.endpoint { color: var(--endpoint); }\n.box.method { color: var(--method); } .box.action { color: var(--action); } .box.component { color: var(--component); }\n.link { fill: none; stroke: var(--line); stroke-width: 1.25; }\n.link.fetches { stroke-dasharray: 5 3; }\n.link.uses { stroke-dasharray: 2 3; }\n.arrow { fill: var(--muted); }\nsvg.focused .box:not(.hot), svg.focused .link:not(.hot), .box.muted { opacity: 0.16; }\n.link.hot { stroke: var(--fg); stroke-width: 2; }\n";
@@ -27,7 +27,7 @@ export declare class MigrationCommand {
27
27
  static parse(argv: string[], invokedAs?: string): MigrationArgs;
28
28
  static run(runner: MigrationRunner, args: MigrationArgs): Promise<MigrationCommandResult>;
29
29
  static failure(action: MigrationAction | null, error: unknown): MigrationCommandResult;
30
- static exitCode(result: MigrationCommandResult): 0 | 1 | 2 | 3;
31
- static print(result: MigrationCommandResult, json: boolean): Promise<0 | 1 | 2 | 3>;
30
+ static exitCode(result: MigrationCommandResult): 1 | 0 | 2 | 3;
31
+ static print(result: MigrationCommandResult, json: boolean): Promise<1 | 0 | 2 | 3>;
32
32
  static render(result: MigrationCommandResult, json: boolean): string;
33
33
  }
@@ -8,5 +8,5 @@ export declare class Migrations {
8
8
  /** On a started server: the runner writes through its database adaptor. */
9
9
  runner(server: AkanServer): MigrationRunner;
10
10
  /** Boots `server` with no listener or schedule, runs one action, and prints its result last. Returns the exit code. */
11
- command(server: AkanServer, argv: string[], invokedAs?: string): Promise<0 | 1 | 2 | 3>;
11
+ command(server: AkanServer, argv: string[], invokedAs?: string): Promise<1 | 0 | 2 | 3>;
12
12
  }
@@ -3,7 +3,7 @@ import { type AkanI18nConfig, type LogRecord } from "akanjs/common";
3
3
  import { type AkanRequestStore } from "akanjs/fetch";
4
4
  import type { AkanMetricsReport } from "akanjs/service";
5
5
  import type { PagePromptSource } from "../signal/mcp/pagePrompt.d.ts";
6
- import { type BuilderRpc, type RouteSeedIndex, type RoutesManifest } from "./artifact.d.ts";
6
+ import { type BuilderRpc, type BuildGraphPayload, type RouteSeedIndex, type RoutesManifest } from "./artifact.d.ts";
7
7
  import { type RouteCacheInvalidation, type RouteCacheRenderState } from "./cachePolicy.d.ts";
8
8
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub.d.ts";
9
9
  import type { McpAppShellSource } from "./mcp/McpApps.d.ts";
@@ -66,6 +66,7 @@ export declare class WebRouter {
66
66
  hmrHub: HmrWsHub | null;
67
67
  builderRpc: BuilderRpc | null;
68
68
  }>;
69
+ buildGraph(): Promise<BuildGraphPayload | null>;
69
70
  refreshHmrState(): void;
70
71
  hmrBuildErrors(): {
71
72
  phase: string;
@@ -1,13 +1,13 @@
1
1
  /** 입력 표면 look — Input/TextArea/DatePicker 가 공유하는 필드 셸. kind 로 한 줄 필드(field)/멀티라인(area)/네이티브 `<select>`(select), tone 으로 강조/오류 상태를 고른다. */
2
2
  export declare const inputRecipe: (variants?: Omit<({
3
- kind?: "select" | "field" | "area" | undefined;
3
+ kind?: "field" | "select" | "area" | undefined;
4
4
  size?: "xl" | "lg" | "md" | "sm" | "xs" | undefined;
5
5
  tone?: "default" | "error" | "primary" | undefined;
6
6
  } & {
7
7
  class?: import("tailwind-variants/lite").ClassValue;
8
8
  className?: never;
9
9
  }) | ({
10
- kind?: "select" | "field" | "area" | undefined;
10
+ kind?: "field" | "select" | "area" | undefined;
11
11
  size?: "xl" | "lg" | "md" | "sm" | "xs" | undefined;
12
12
  tone?: "default" | "error" | "primary" | undefined;
13
13
  } & {