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 =
|
|
132
|
-
|
|
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
|
@@ -116,7 +116,8 @@ export interface BuilderMetrics {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
export type BuilderEvent =
|
|
119
|
-
|
|
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.
|