akanjs 2.4.1-rc.4 → 2.4.1-rc.5

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.
package/base/baseEnv.ts CHANGED
@@ -128,9 +128,11 @@ export const getEnv = (): ClientEnv => {
128
128
  ? (window.location.host.split(":")[0] ?? "unknown")
129
129
  : "localhost");
130
130
 
131
- const serverPort = parseInt(
132
- process.env.AKAN_PUBLIC_SERVER_PORT ?? (operationMode === "local" || side === "server" ? "8282" : "443"),
133
- );
131
+ const serverPort =
132
+ side === "server"
133
+ ? parseInt(process.env.AKAN_PUBLIC_SERVER_PORT ?? (operationMode === "local" ? "8282" : "443"))
134
+ : parseInt(window.location.port || (window.location.protocol === "https:" ? "443" : "80"));
135
+
134
136
  const serverHttpProtocol: "http:" | "https:" =
135
137
  (process.env.SERVER_HTTP_PROTOCOL as "http:" | "https:" | undefined) ??
136
138
  (operationMode === "local"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.4.1-rc.4",
3
+ "version": "2.4.1-rc.5",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -116,7 +116,8 @@ export interface BuilderMetrics {
116
116
  }
117
117
 
118
118
  export type BuilderEvent =
119
- | { type: "builder-ready"; buildId: string }
119
+
120
+ | { type: "builder-ready"; buildId?: string }
120
121
  | { type: "backend-ready"; pid: number }
121
122
  | {
122
123
  type: "invalidate";
@@ -0,0 +1,670 @@
1
+ # Akan Devtools Metadata API
2
+
3
+ Four read-only JSON endpoints that describe a running Akan application well enough to draw it: its data
4
+ schema, its API surface, its i18n tree, and its dependency-injection graph.
5
+
6
+ They exist so an external developer-tools UI can render the system without parsing source code. This
7
+ document is the integration contract — endpoints, payload schemas, real examples, and a suggested
8
+ visualization for each.
9
+
10
+ ---
11
+
12
+ ## 1. Availability
13
+
14
+ | | |
15
+ | --- | --- |
16
+ | **Base path** | `/_akan` (never under `/api`) |
17
+ | **Method** | `GET` only |
18
+ | **Auth** | none — the gate is the environment, not a credential |
19
+ | **Enabled when** | `AKAN_PUBLIC_ENV=local`, or `AKAN_DEVTOOLS=true` / `AKAN_DEVTOOLS=1` |
20
+ | **Disabled when** | any other environment, or `AKAN_DEVTOOLS=false` / `AKAN_DEVTOOLS=0` |
21
+ | **Response headers** | `Content-Type: application/json; charset=utf-8`, `Cache-Control: no-store` |
22
+
23
+ **When disabled, the routes are not registered at all.** They are not 403s — the request falls through to
24
+ the app's SSR catch-all, which typically answers `307` (locale/base-path redirect) or `404`. Feature-detect
25
+ by requesting `/_akan/devtools` and treating **any non-200, or any 200 whose body is not JSON with
26
+ `version: 1`,** as "devtools unavailable". Do not branch on the status code alone.
27
+
28
+ ```bash
29
+ bun run akan start <appName> # dev server, default port 8282
30
+ curl -s localhost:8282/_akan/devtools | jq .
31
+ ```
32
+
33
+ In federation mode the gateway proxies unknown `/_akan/*` paths to a backend child, so a single origin
34
+ serves all four. Each response reports the `pid` and `replicaIdx` that answered it (see
35
+ [§2](#2-response-envelope)); with multiple replicas, consecutive requests may land on different processes.
36
+ That matters only for `/_akan/signal`, whose `internal[].scheduledHere` is per-process.
37
+
38
+ ---
39
+
40
+ ## 2. Response envelope
41
+
42
+ Every payload endpoint returns the same wrapper. Validate this once and reuse it.
43
+
44
+ ```ts
45
+ interface DevtoolsEnvelope<Kind extends string, Data> {
46
+ kind: Kind; // "constant" | "signal" | "dictionary" | "deps"
47
+ version: 1; // contract version — bumped on any breaking shape change
48
+ generatedAt: string; // ISO-8601, when this response was built (not cached)
49
+ app: {
50
+ name: string; // AKAN_PUBLIC_APP_NAME
51
+ repoName: string;
52
+ serveDomain: string;
53
+ environment: string; // "local" | "debug" | "main" | ...
54
+ operationMode: string; // "local" | "edge" | "cloud" | "module"
55
+ serverMode: "federation" | "batch" | "all";
56
+ pid: number;
57
+ replicaIdx: number;
58
+ };
59
+ data: Data;
60
+ }
61
+ ```
62
+
63
+ **Gate your client on `version`.** Treat an unrecognized `version` as unsupported rather than best-effort
64
+ parsing it. New optional fields may be added within `version: 1`; nothing will be removed or retyped.
65
+
66
+ ### Errors
67
+
68
+ A serializer failure returns `500` with a flat body — the envelope is *not* present:
69
+
70
+ ```json
71
+ { "kind": "signal", "error": "Cannot destructure property 'endpointCls' from null or undefined value" }
72
+ ```
73
+
74
+ Render it as a per-panel error, not a global failure. The other three endpoints are independent and will
75
+ still succeed.
76
+
77
+ ### The `Unserializable` marker
78
+
79
+ Some registry values (an arbitrary `meta` object, a default factory, a cyclic graph) cannot be represented
80
+ in JSON. Instead of dropping them or throwing, the server substitutes a marker. It can appear anywhere a
81
+ value is user-supplied: `ConstantFieldNode.default` / `.example` / `.meta` / `.accumulate`,
82
+ `ConstantData.values`, `DepEdge.detail`.
83
+
84
+ ```ts
85
+ interface Unserializable {
86
+ __akan: "unserializable";
87
+ type: "function" | "class" | "symbol" | "bigint" | "circular" | "depth-limit";
88
+ name?: string;
89
+ }
90
+ ```
91
+
92
+ Detect it with `value?.__akan === "unserializable"` and render a badge (`ƒ`, `⟳`, `…`) rather than the raw
93
+ object. Also note that non-finite numbers arrive as the strings `"NaN"`, `"Infinity"`, `"-Infinity"`.
94
+
95
+ ---
96
+
97
+ ## 3. `GET /_akan/devtools` — index
98
+
99
+ The only endpoint without an envelope. Use it for feature detection and discovery.
100
+
101
+ ```json
102
+ {
103
+ "version": 1,
104
+ "endpoints": [
105
+ { "kind": "constant", "path": "/_akan/constant" },
106
+ { "kind": "signal", "path": "/_akan/signal" },
107
+ { "kind": "dictionary", "path": "/_akan/dictionary" },
108
+ { "kind": "deps", "path": "/_akan/deps" }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 4. `GET /_akan/constant` — data schema
116
+
117
+ Every model the app registered, in all five of its generated views, plus scalars, enums, filters, and
118
+ pre-derived relation edges.
119
+
120
+ ### Schema
121
+
122
+ ```ts
123
+ interface ConstantData {
124
+ models: Record<string, ConstantModelNode>; // keyed by refName, e.g. "user"
125
+ scalars: Record<string, ConstantModelView>; // embedded value objects, no table of their own
126
+ enums: Record<string, EnumNode>;
127
+ values: Record<string, unknown>; // other exports from *.constant.ts, JSON-coerced
128
+ primitives: string[]; // e.g. ["Any","Boolean","Date","Float","ID","Int","String","Upload"]
129
+ relations: RelationEdge[]; // pre-derived, see the caveat below
130
+ }
131
+
132
+ type ModelViewKey = "input" | "object" | "full" | "light" | "insight";
133
+ type ConstantType = ModelViewKey | "scalar";
134
+
135
+ interface ConstantModelNode {
136
+ refName: string; // "user"
137
+ modelNames: Record<ModelViewKey, string>; // { full: "User", light: "LightUser", ... }
138
+ views: Record<ModelViewKey, ConstantModelView>;
139
+ filter?: { query: Record<string, FilterArg[]>; sort: string[] };
140
+ }
141
+
142
+ interface ConstantModelView {
143
+ modelName: string; // "User"
144
+ modelType: ConstantType; // "full"
145
+ fields: Record<string, ConstantFieldNode>;
146
+ }
147
+
148
+ interface ConstantFieldNode {
149
+ name: string;
150
+ fieldKind: "property" | "hidden" | "secret" | "resolve";
151
+ type: FieldType;
152
+ arrDepth: number; // 0 = scalar, 1 = T[], 2 = T[][]
153
+ nullable: boolean;
154
+ immutable: boolean;
155
+ select: boolean; // false = excluded from default queries
156
+ defaultKind: "value" | "function" | "none";
157
+ default?: unknown; // present only when defaultKind === "value"
158
+ ref?: string; // referenced model refName, when the field is a foreign key
159
+ refPath?: string;
160
+ refType?: "child" | "parent" | "relation";
161
+ min?: number; max?: number; minlength?: number; maxlength?: number;
162
+ preset?: "email" | "password" | "url";
163
+ text?: "search" | "filter";
164
+ accumulate?: unknown; // insight aggregation spec
165
+ example?: unknown;
166
+ meta?: Record<string, unknown>;
167
+ hasValidate: boolean; // a custom validator exists; its body is not serialized
168
+ }
169
+
170
+ type FieldType =
171
+ | { kind: "primitive"; refName: string } // String, Int, ID, Date, ...
172
+ | { kind: "enum"; refName: string; values: (string | number)[] } // values inlined
173
+ | { kind: "model"; refName: string; modelType: ConstantType; modelName: string } // reference only
174
+ | { kind: "map"; value: FieldType; valueArrDepth: number } // Map<string, value>
175
+ | { kind: "unknown"; refName: string }; // unregistered class
176
+
177
+ interface FilterArg {
178
+ name: string; type: FieldType; arrDepth: number; nullable: boolean;
179
+ ref?: string; // the model this arg points at — draw it as a cross-model link
180
+ default?: unknown;
181
+ }
182
+
183
+ interface EnumNode {
184
+ key: string; // the ConstantRegistry map key (lowerlized export name)
185
+ refName: string; // the enum's own refName — may differ from `key`
186
+ values: (string | number)[];
187
+ }
188
+
189
+ interface RelationEdge {
190
+ from: string; fromView: ConstantType; field: string;
191
+ to: string; toView: ConstantType;
192
+ arrDepth: number; nullable: boolean;
193
+ refType?: "child" | "parent" | "relation";
194
+ }
195
+ ```
196
+
197
+ ### Example
198
+
199
+ ```jsonc
200
+ // data.models.serverResolverTestItem.views.full.fields
201
+ {
202
+ "count": { "name": "count", "fieldKind": "property", "type": { "kind": "primitive", "refName": "Int" },
203
+ "arrDepth": 0, "nullable": false, "immutable": false, "select": true,
204
+ "defaultKind": "value", "default": 0, "hasValidate": false },
205
+ "tags": { "name": "tags", "fieldKind": "property", "type": { "kind": "primitive", "refName": "String" },
206
+ "arrDepth": 1, "nullable": false, "immutable": false, "select": true,
207
+ "defaultKind": "value", "default": [], "hasValidate": false },
208
+ "nested": { "name": "nested", "fieldKind": "property",
209
+ "type": { "kind": "model", "refName": "serverResolverTestNested",
210
+ "modelType": "scalar", "modelName": "ServerResolverTestNested" },
211
+ "arrDepth": 0, "nullable": false, "immutable": false, "select": true,
212
+ "defaultKind": "none", "hasValidate": false },
213
+ "secret": { "name": "secret", "fieldKind": "secret", "type": { "kind": "primitive", "refName": "String" },
214
+ "arrDepth": 0, "nullable": true, "immutable": false, "select": false,
215
+ "defaultKind": "none", "hasValidate": false },
216
+ "resolvedLabel": { "name": "resolvedLabel", "fieldKind": "resolve",
217
+ "type": { "kind": "primitive", "refName": "String" },
218
+ "arrDepth": 0, "nullable": false, "immutable": false, "select": true,
219
+ "defaultKind": "none", "hasValidate": false }
220
+ }
221
+ ```
222
+
223
+ ```jsonc
224
+ // data.models.serverResolverTestItem.filter
225
+ {
226
+ "query": {
227
+ "any": [],
228
+ "inCategory": [
229
+ { "name": "category", "type": { "kind": "primitive", "refName": "String" }, "arrDepth": 0, "nullable": false },
230
+ { "name": "includeRemoved", "type": { "kind": "primitive", "refName": "Boolean" }, "arrDepth": 0, "nullable": true }
231
+ ],
232
+ "byOwner": [
233
+ { "name": "ownerId", "type": { "kind": "primitive", "refName": "ID" }, "arrDepth": 0, "nullable": false, "ref": "user" }
234
+ ]
235
+ },
236
+ "sort": ["latest", "oldest", "titleAsc"]
237
+ }
238
+ ```
239
+
240
+ ### Reading it correctly
241
+
242
+ - **`relations` is emitted per view.** The same field yields up to five edges (`fromView`
243
+ `input`/`object`/`full`/`light`/`insight`). For an ER diagram, **filter to `fromView === "full"`**;
244
+ otherwise every relation is drawn five times.
245
+ - **The five views are projections of one model, not five models.** Default to rendering `full` and offer
246
+ the rest as a toggle. `input` is the write shape, `light` the list shape, `insight` the aggregate shape.
247
+ - **`fieldKind` drives styling.** `secret` → mask it (see [§8](#8-what-is-deliberately-absent)); `hidden` →
248
+ server-only, dim it; `resolve` → computed on demand, not stored, so mark it distinctly in an ER diagram;
249
+ `property` → normal.
250
+ - **`type.kind === "model"` never inlines the target.** Follow `type.refName` into `models` or `scalars`.
251
+ That keeps the *payload* finite, but the model graph it describes can still be cyclic (`user → post →
252
+ user`), so guard your own traversal if you expand references into a tree.
253
+ - **Enum values are inlined on the field** (`type.values`). The top-level `enums` map is a separate index
254
+ and can legitimately be empty even when enum-typed fields exist — do not resolve field enums through it.
255
+ - **`arrDepth` is not part of `type`.** Render `String` + `arrDepth: 1` as `String[]`.
256
+ - **`defaultKind: "function"`** means a factory like `() => dayjs()`. It is never invoked, so there is no
257
+ value to show — render `ƒ` or "computed".
258
+ - **`scalars` are embedded value objects.** In an ER diagram draw them as composition (filled diamond),
259
+ not as a separate entity with its own identity.
260
+
261
+ ### Suggested visualization — **ER diagram + schema browser**
262
+
263
+ - **Primary: entity-relationship graph.** One node per `models` key, labelled `modelNames.full`. Node body
264
+ lists the `full` view's fields as `name: Type[]` with a nullability marker. Edges from
265
+ `relations.filter(r => r.fromView === "full")`, labelled with `field`, arrow style from `refType`
266
+ (`parent`/`child`/`relation`), cardinality `1` vs `N` from `arrDepth > 0`, dashed when `nullable`.
267
+ Force-directed or layered layout both work; models are typically < 50.
268
+ - **Secondary: model detail panel.** View tabs (`input`/`object`/`full`/`light`/`insight`) over one field
269
+ table with columns *field, type, flags, default, constraints*. A diff between `input` and `full` is a
270
+ cheap high-value feature — it shows exactly what the server adds to a write.
271
+ - **Filter explorer.** Under each model, `filter.query` as a list of named queries with their arg
272
+ signatures, and `filter.sort` as chips. Any `FilterArg.ref` is a link to another model — a genuinely
273
+ useful cross-model view that the ER graph alone does not give you.
274
+ - **Enum palette.** A flat list from `enums` with value chips, cross-referenced to the fields that use
275
+ them (match on `type.refName`).
276
+
277
+ ---
278
+
279
+ ## 5. `GET /_akan/signal` — API surface
280
+
281
+ Every endpoint the server actually serves — hand-written and framework-generated — plus slices, internals,
282
+ and a flattened route table.
283
+
284
+ ### Schema
285
+
286
+ ```ts
287
+ interface SignalData {
288
+ prefix: string; // "/api"
289
+ websocketPrefix: string; // "/ws"
290
+ signals: Record<string, SignalNode>;
291
+ routes: RouteRow[]; // flattened, render-ready
292
+ guards: string[]; // every distinct guard name in the app
293
+ middlewares: string[];
294
+ }
295
+
296
+ interface SignalNode {
297
+ refName: string;
298
+ kind: "database" | "service"; // "database" signals have slices and generated CRUD
299
+ cnstRefName?: string; // the model in /_akan/constant this signal is bound to
300
+ classNames: { internal?: string; endpoint: string; slice?: string; server?: string };
301
+ guards: { get?: string[]; cru?: string[]; create?: string[]; update?: string[]; remove?: string[] };
302
+ internal: Record<string, InternalNode>;
303
+ slice: Record<string, SliceNode>;
304
+ endpoint: Record<string, EndpointNode>; // hand-written in *.signal.ts
305
+ generated: {
306
+ crud: Record<string, EndpointNode>; // create/update/remove/get/light
307
+ slice: Record<string, EndpointNode>; // <model>List<Suffix> / <model>Insight<Suffix>
308
+ };
309
+ }
310
+
311
+ interface EndpointNode {
312
+ type: "query" | "mutation" | "pubsub" | "message";
313
+ args: SerializedArg[];
314
+ returns: SerializedReturns;
315
+ path?: string; // explicit override
316
+ prefix?: false | string; // false = skip the per-model path segment
317
+ globalPrefix?: false; // false = skip "/api"
318
+ guards?: string[];
319
+ fileUpload?: boolean;
320
+ cache?: number; // declared on the endpoint; no runtime consumer today (see note)
321
+ timeout?: number; // milliseconds, enforced by the `Timeout` middleware (default 5000)
322
+ }
323
+
324
+ interface SliceNode { args: SerializedArg[]; path?: string; guards?: string[] }
325
+
326
+ interface SerializedArg {
327
+ type: "body" | "param" | "search" | "upload" | "msg" | "room";
328
+ refName: string; // primitive name, or a model refName
329
+ name: string;
330
+ modelType?: "input" | "object" | "insight" | "scalar"; // absent for primitives
331
+ arrDepth?: number;
332
+ nullable?: boolean;
333
+ example?: string | number | boolean;
334
+ enum?: string;
335
+ }
336
+
337
+ interface SerializedReturns {
338
+ refName: string;
339
+ modelType?: "input" | "full" | "light" | "insight" | "scalar";
340
+ arrDepth?: number;
341
+ partial?: string[];
342
+ nullable?: boolean;
343
+ }
344
+
345
+ interface InternalNode {
346
+ key: string;
347
+ type: "init" | "destroy" | "cron" | "interval" | "timeout" | "process" | "resolveField";
348
+ enabled: boolean;
349
+ lock?: boolean;
350
+ serverMode?: "federation" | "batch" | "all";
351
+ operationMode?: string[];
352
+ schedule?: { cron?: string; everyMs?: number };
353
+ args: SerializedArg[];
354
+ internalArgs: string[]; // injected context classes, e.g. ["Req", "Ws"]
355
+ returns?: SerializedReturns;
356
+ scheduledHere: boolean; // is it actually running on THIS process?
357
+ skipReason?: string; // human-readable why-not
358
+ }
359
+
360
+ interface RouteRow {
361
+ signal: string;
362
+ key: string;
363
+ source: "declared" | "crud" | "slice";
364
+ type: "query" | "mutation" | "message" | "pubsub";
365
+ transport: "http" | "ws";
366
+ method: "GET" | "POST" | null; // null for websocket
367
+ path: string; // fully resolved, ":param" placeholders intact
368
+ guards: string[];
369
+ cache?: number; timeout?: number; fileUpload?: boolean;
370
+ }
371
+ ```
372
+
373
+ ### Example
374
+
375
+ ```jsonc
376
+ // data.routes — note how prefix/globalPrefix change the resolved path
377
+ [
378
+ { "signal": "serverResolverTestItem", "key": "getTitle", "source": "declared", "type": "query",
379
+ "transport": "http", "method": "GET", "path": "/getTitle/:id", "guards": ["Public"] },
380
+ { "signal": "serverResolverTestItem", "key": "updateTitle", "source": "declared", "type": "mutation",
381
+ "transport": "http", "method": "POST", "path": "/api/serverResolverTestItem/updateTitle/:id", "guards": [] },
382
+ { "signal": "serverResolverTestItem", "key": "roomFeed", "source": "declared", "type": "pubsub",
383
+ "transport": "ws", "method": null, "path": "/api/ws", "guards": [] },
384
+ { "signal": "serverResolverTestItem", "key": "serverResolverTestItemList", "source": "slice", "type": "query",
385
+ "transport": "http", "method": "GET",
386
+ "path": "/api/serverResolverTestItem/serverResolverTestItemList", "guards": ["Public"] }
387
+ ]
388
+ ```
389
+
390
+ ```jsonc
391
+ // data.signals.serverResolverTestItem.internal
392
+ {
393
+ "processItem": { "key": "processItem", "type": "process", "enabled": true, "serverMode": "all",
394
+ "args": [{ "type": "msg", "refName": "ID", "name": "itemId" }],
395
+ "internalArgs": [], "returns": { "refName": "Boolean" }, "scheduledHere": true },
396
+ "cleanup": { "key": "cleanup", "type": "destroy", "enabled": true, "lock": true,
397
+ "args": [], "internalArgs": [], "returns": { "refName": "Any" }, "scheduledHere": true }
398
+ }
399
+ ```
400
+
401
+ ### Reading it correctly
402
+
403
+ - **`routes` is the render-ready view; `signals` is the structured view.** Build the route table from
404
+ `routes` — the paths are already resolved exactly as the server registered them, including
405
+ `prefix: false` / `globalPrefix: false` cases (`getTitle` above resolves to a bare `/getTitle/:id`).
406
+ Do not recompute paths from `prefix` + `key`; you will get them wrong.
407
+ - **`source` distinguishes authored from generated.** Most apps have far more `crud`/`slice` endpoints than
408
+ `declared` ones. Default the table to `declared` and let the user opt into the generated ones, or the
409
+ hand-written API will be buried.
410
+ - **Slices with an empty-string key are the root slice.** They generate the unsuffixed
411
+ `<model>List` / `<model>Insight` pair. Label them "root", not "".
412
+ - **`guards: []` means no guard was declared** at the endpoint level. For generated CRUD, authorization
413
+ comes from the *signal-level* `guards` object (`get`/`cru`/`create`/`update`/`remove`) instead — read
414
+ both before telling a user an endpoint is unprotected.
415
+ - **All websocket endpoints share one path** (`/api/ws`); `key` is the message discriminator. Do not group
416
+ the route table by path for ws rows.
417
+ - **`scheduledHere` is per-process.** In federation mode, `false` with a `skipReason` means "this job runs
418
+ on a different server role", not "broken". Surface `skipReason` verbatim — it is written to answer
419
+ "why isn't my cron running".
420
+ - **`resolveField` internals are never scheduled**; they carry a fixed `skipReason` saying so. Do not show
421
+ them as disabled jobs.
422
+ - **`timeout` is milliseconds; `cache` currently has no runtime effect.** `timeout` is enforced by the
423
+ `Timeout` middleware (default 5000ms when absent). `cache` is a declared endpoint option that nothing in
424
+ the framework consumes yet — report it as a declaration, not as active behaviour.
425
+
426
+ ### Suggested visualization — **API explorer + job timeline**
427
+
428
+ - **Primary: route table.** Columns *method · path · signal · key · source · type · guards*, with facets on
429
+ `source`, `type`, `transport`, `signal`, and guard. Method as a colored chip. This is the single highest
430
+ value view; make it the default tab.
431
+ - **Signal detail.** Per signal, four sections — declared endpoints, generated CRUD, slices, internals —
432
+ each row expanding to its arg list (`SerializedArg.type` tells you `param` vs `search` vs `body`) and
433
+ return type. Link `returns.refName` / `args[].refName` into the `/_akan/constant` model browser: this
434
+ cross-link is what turns two payloads into one product.
435
+ - **Scheduled-jobs panel.** From every `internal` where `type` is `cron`/`interval`/`timeout`. Show
436
+ `schedule.cron` or `schedule.everyMs` as a human string, and render `scheduledHere: false` rows greyed
437
+ with `skipReason` as the subtitle. A 24-hour timeline strip of upcoming cron fires is a strong feature if
438
+ you want one.
439
+ - **Lifecycle strip.** `init` and `destroy` internals in boot order — pairs naturally with the
440
+ `/_akan/deps` stage view.
441
+ - **Guard/middleware coverage.** A matrix of endpoints × guards, driven by top-level `guards`. Endpoints
442
+ with no guard at either level are the interesting cell.
443
+
444
+ ---
445
+
446
+ ## 6. `GET /_akan/dictionary` — i18n tree
447
+
448
+ Every registered translation, merged across the framework, libraries, and the app.
449
+
450
+ **Query parameters:** `?lang=<code>` narrows `dictionary` and `keys` to one language. `languages` and
451
+ `modules` always report the full set. Full dictionaries reach hundreds of keys per language — prefer
452
+ `?lang=` for the initial load.
453
+
454
+ ### Schema
455
+
456
+ ```ts
457
+ interface DictionaryData {
458
+ languages: string[]; // ["en", "ko"]
459
+ modules: Record<string, { kind: "model" | "scalar" | "service"; languages: string[] }>;
460
+ dictionary: Record<string, Record<string, DictionaryNode>>; // [lang][refName] -> tree
461
+ keys: string[]; // flattened dotted paths, sorted
462
+ }
463
+
464
+ // A node is a plain nested object. Leaves carry `t`; a sibling `desc` holds the long form.
465
+ type DictionaryNode = { t?: string; desc?: { t: string } } & Record<string, unknown>;
466
+ ```
467
+
468
+ ### Example
469
+
470
+ ```jsonc
471
+ {
472
+ "languages": ["en", "ko"],
473
+ "modules": {
474
+ "user": { "kind": "model", "languages": ["en", "ko"] },
475
+ "util": { "kind": "service", "languages": ["en", "ko"] }
476
+ },
477
+ "dictionary": {
478
+ "en": {
479
+ "user": {
480
+ "modelName": { "t": "User" },
481
+ "modelDesc": { "t": "A user account" },
482
+ "insight": {}, "query": {}, "sort": {}, "signal": {}, "error": {},
483
+ "empty": { "t": "No users" }
484
+ },
485
+ "util": {
486
+ "signal": { "ping": { "t": "Ping", "arg": {}, "desc": { "t": "Health check" } } },
487
+ "error": { "unavailable": { "t": "Unavailable" } }
488
+ }
489
+ }
490
+ },
491
+ "keys": ["user.empty", "user.modelDesc", "user.modelName",
492
+ "util.error.unavailable", "util.signal.ping", "util.signal.ping.desc"]
493
+ }
494
+ ```
495
+
496
+ ### Reading it correctly
497
+
498
+ - **`keys` is the union across whatever `dictionary` contains.** Without `?lang=` that is every language,
499
+ so a key present in `keys` may be missing from one language's tree — that difference *is* the coverage
500
+ report. With `?lang=` both narrow together, so the coverage view needs the unfiltered fetch.
501
+ - **Structural sections are conventional:** `modelName` / `modelDesc`, then `insight`, `query`, `sort`,
502
+ `signal`, `error`, with loose keys at the top level. Endpoint arg labels live at
503
+ `<module>.signal.<endpointKey>.arg.<argName>`. Empty section objects (`"query": {}`) are normal.
504
+ - **`.desc` is a sibling of the leaf, not a separate key** — `util.signal.ping` and `util.signal.ping.desc`
505
+ are the same node, one nested inside the other. Present them as one row with a description column.
506
+ - **Module refNames align with `/_akan/constant` models and `/_akan/signal` signals.** `modules[x].kind`
507
+ tells you which: `model` → a model in `constant.models`, `service` → a signal in `signal.signals`.
508
+
509
+ ### Suggested visualization — **translation matrix**
510
+
511
+ - **Primary: key × language grid.** Rows from `keys`, one column per language, cells from the tree. Empty
512
+ cell = missing translation. A per-language completeness bar at the top is the headline number.
513
+ - **Group rows by module** (the first dotted segment), badged with `modules[x].kind`. Within a module,
514
+ sub-group by the second segment (`signal`, `error`, `query`, …) so a 300-row list stays navigable.
515
+ - **Filter to gaps** — a "missing only" toggle is the reason this view earns a place in the product.
516
+ - **Inline context.** When a key resolves to a field or endpoint, deep-link to it in the constant/signal
517
+ browsers. `user.modelName` ↔ the `user` model; `util.signal.ping` ↔ the `ping` endpoint.
518
+
519
+ ---
520
+
521
+ ## 7. `GET /_akan/deps` — dependency graph
522
+
523
+ The DI container as a node/edge graph, plus the topological order it booted in.
524
+
525
+ ### Schema
526
+
527
+ ```ts
528
+ interface DepsData {
529
+ app: {
530
+ name: string; status: string; // "running" | "initializing" | ...
531
+ serverMode: "federation" | "batch" | "all";
532
+ prefix: string; websocketPrefix: string; openapi: boolean;
533
+ };
534
+ nodes: DepNode[];
535
+ edges: DepEdge[];
536
+ roles: { role: string; impl: string }[]; // interface -> bound implementation
537
+ stages: { adaptor: string[][]; service: string[][] }; // topological init batches
538
+ env: {
539
+ public: Record<string, string>; // AKAN_PUBLIC_* only, with values
540
+ keys: string[]; // every process.env name plus the resolved BaseEnv keys — names only
541
+ };
542
+ disabledModules: { refName: string; reason: string }[];
543
+ }
544
+ ```
545
+
546
+ > `data.app` here is the *server* identity (status, prefix, openapi). The envelope's `app`
547
+ > ([§2](#2-response-envelope)) is the *environment* identity (appName, environment, pid). They are
548
+ > different objects; both are useful in a header bar.
549
+
550
+ ```ts
551
+ interface DepNode {
552
+ id: string; // "service:user" — kind-prefixed, unique
553
+ kind: "service" | "adaptor" | "serverSignal" | "internal" | "endpoint"
554
+ | "slice" | "use" | "middleware" | "webProxy" | "env";
555
+ refName: string;
556
+ className?: string;
557
+ stage?: number; // index into stages.service / stages.adaptor — use as graph rank
558
+ enabled?: boolean;
559
+ serviceType?: "database" | "plain";
560
+ cnstRefName?: string;
561
+ role?: string; // set when this adaptor is bound to a role
562
+ }
563
+
564
+ interface DepEdge {
565
+ from: string; to: string; // DepNode ids
566
+ kind: "database" | "service" | "use" | "signal" | "plug" | "env" | "memory";
567
+ prop: string; // the injected field name on the source class
568
+ resolvedTo?: string; // plug only: the concrete adaptor a role resolved to
569
+ detail?: Record<string, unknown>; // env -> { keys: string[] }; memory -> { local, isMap, expireAt? }
570
+ }
571
+ ```
572
+
573
+ ### Example
574
+
575
+ ```jsonc
576
+ {
577
+ "nodes": [
578
+ { "id": "service:serverResolverTestItem", "kind": "service", "refName": "serverResolverTestItem",
579
+ "className": "serverResolverTestItem", "serviceType": "database", "enabled": true,
580
+ "stage": 0, "cnstRefName": "serverResolverTestItem" },
581
+ { "id": "adaptor:cacheAdaptorRole", "kind": "adaptor", "refName": "cacheAdaptorRole",
582
+ "className": "cacheAdaptorRole" }
583
+ ],
584
+ "edges": [
585
+ { "from": "service:serverResolverTestItem", "to": "adaptor:serverResolverTestItemModel",
586
+ "kind": "database", "prop": "serverResolverTestItemModel" },
587
+ { "from": "service:base", "to": "serverSignal:baseSignal", "kind": "signal", "prop": "baseSignal" },
588
+ { "from": "service:base", "to": "env:env", "kind": "env", "prop": "onCleanup", "detail": { "keys": [] } }
589
+ ],
590
+ "roles": [
591
+ { "role": "cacheAdaptorRole", "impl": "solidCache" },
592
+ { "role": "databaseAdaptorRole", "impl": "sqliteDatabase" }
593
+ ],
594
+ "stages": {
595
+ "adaptor": [["sqliteDatabase", "solidCache", "blobStorage", "solidQueue", "consoleLogger"],
596
+ ["scheduler", "serverResolverTestItemModel"]],
597
+ "service": [["base", "serverResolverTestItem"]]
598
+ }
599
+ }
600
+ ```
601
+
602
+ ### Reading it correctly
603
+
604
+ - **`stages` is a free layered layout.** Each inner array is one init batch; everything in a batch is
605
+ independent and boots in parallel. Use the array index as the graph rank instead of running your own
606
+ topological sort — the server already resolved the real order.
607
+ - **Edges are not deduplicated.** One target can be reached by several injected props (a service reaching
608
+ its model as both `<model>Model` and `__databaseModel`). Dedupe on `from|to|kind` for the graph, and keep
609
+ the full list for the detail panel.
610
+ - **`roles` is the interface-to-implementation binding** (`cacheAdaptorRole → solidCache`). This is what a
611
+ reader most wants to know about the container. Render role nodes distinctly and draw a dashed
612
+ "implemented by" edge, or collapse the role into its impl with the role as a subtitle.
613
+ - **`plug` edges point at the *declared* target**, which is usually a role. `resolvedTo` carries the
614
+ concrete adaptor when they differ — show it as "→ via".
615
+ - **`env` is a single sink node.** Every `env` edge lands on `env:env`; `detail.keys` lists the key *names*
616
+ that injection reads. Those names are extracted by static analysis, so they are best-effort and slightly
617
+ over-inclusive — do not present them as an authoritative config contract.
618
+ - **`disabledModules` explains absence.** A module named there has no node at all; the `reason` string
619
+ ("service disabled", `depends on disabled module "x"`) is the whole story. Render it as a sidebar list —
620
+ "why isn't my module here" is a real support question.
621
+ - **`nodes[].refName` cross-links out:** `service` and `serverSignal` nodes match `/_akan/signal` signals;
622
+ `cnstRefName` matches a `/_akan/constant` model.
623
+
624
+ ### Suggested visualization — **layered DI graph + boot timeline**
625
+
626
+ - **Primary: layered DAG.** Rank by `stage`, one swimlane per `kind`, node color by `kind`, edge style by
627
+ edge `kind` (solid `service`, dashed `plug`, dotted `env`). Start with `service` + `adaptor` + `env` only
628
+ — including `endpoint`/`slice`/`internal` nodes triples the node count for little insight. Make them a
629
+ toggle.
630
+ - **Boot timeline.** `stages.adaptor` then `stages.service` as a horizontal waterfall of batches. This is
631
+ the clearest picture of startup and reads well even for someone who does not know the codebase.
632
+ - **Node inspector.** On select: class name, stage, `serviceType`, inbound/outbound edges grouped by edge
633
+ kind with the `prop` name, and the role binding if any.
634
+ - **Blast-radius mode.** Highlight the transitive closure downstream of a selected node — "what breaks if
635
+ this adaptor fails". Cheap to implement on this payload and the most compelling demo of the view.
636
+ - **Config panel.** `env.public` as a key/value table, `env.keys` as names-only chips (values are
637
+ deliberately absent — see below), and `roles` as a binding table.
638
+
639
+ ---
640
+
641
+ ## 8. What is deliberately absent
642
+
643
+ These omissions are intentional. Please do not build UI that implies the data is available, and do not ask
644
+ for it to be added.
645
+
646
+ | Omitted | Where | Why |
647
+ | --- | --- | --- |
648
+ | Values of secret fields | `ConstantFieldNode` with `fieldKind: "secret"` carries no `default` / `example` | field names are structure; seeded values are not |
649
+ | Non-public env values | `deps.env.public` holds `AKAN_PUBLIC_*` only; every other key appears in `env.keys` by name | credentials must not leave the process |
650
+ | Live `use` instances | `deps.nodes` of kind `use` carry key + `className` only | SDK clients routinely close over credentials |
651
+ | Function bodies | `hasValidate: boolean`, `defaultKind: "function"`, `Unserializable` markers | not serializable, and not useful to render |
652
+ | Runtime data | all four endpoints | this is a description of the *shape* of the system, never its contents. No records, no request logs, no metrics |
653
+
654
+ ---
655
+
656
+ ## 9. Client integration notes
657
+
658
+ - **Fetch order.** `/_akan/devtools` first to feature-detect, then the four in parallel. They are
659
+ independent; render each panel as it arrives and degrade a failed one individually.
660
+ - **Caching.** Responses are `no-store` and rebuilt per request. The shape only changes on a server
661
+ restart, so cache client-side keyed on `app.pid` + `generatedAt` and refetch on user action rather than
662
+ polling. There is no change feed and no websocket for these.
663
+ - **Cost.** `/_akan/constant` and `/_akan/dictionary` grow with the app — hundreds of KB on a large
664
+ codebase. Use `?lang=` for the dictionary and lazily load model detail from the already-fetched constant
665
+ payload rather than refetching.
666
+ - **Stability.** Within `version: 1`, optional fields may be added; nothing will be removed or retyped.
667
+ Parse defensively — treat every `?`-marked field as possibly absent, and unknown enum members in
668
+ `fieldKind` / `kind` / `type` as a generic fallback rather than an error.
669
+ - **Source of truth.** The TypeScript definitions in this document are copied from
670
+ `pkgs/akanjs/server/devtools/types.ts`. That file is the contract; this document is its explanation.
@@ -130,7 +130,7 @@ export interface BuilderMetrics {
130
130
  }
131
131
  export type BuilderEvent = {
132
132
  type: "builder-ready";
133
- buildId: string;
133
+ buildId?: string;
134
134
  } | {
135
135
  type: "backend-ready";
136
136
  pid: number;