@akanjs/cli 3.1.0-rc.4 → 3.1.0-rc.6
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/.build-stamp +1 -1
- package/{CodeAgent-ymte1tv8.js → CodeAgent-rkjyxgec.js} +2 -2
- package/{CodeTui-4rhdm1qw.js → CodeTui-h7pyj6xa.js} +1 -1
- package/TunnelShare-661cxz5e.js +14 -0
- package/{agent.command-r5x1mch5.js → agent.command-3qxf4xcj.js} +5 -5
- package/akanCode.js +2 -2
- package/{application.command-3dkr7rdz.js → application.command-m1kcd7jf.js} +12 -11
- package/{applicationBuildRunner-h0j2swx1.js → applicationBuildRunner-dj0xkrep.js} +16 -9
- package/buildBatch.proc.js +7 -7
- package/{cloud.command-4kyxfw0y.js → cloud.command-3jzym8q7.js} +17 -16
- package/cloud.script-r4nskbc6.js +24 -0
- package/{code.command-sjvzt8fc.js → code.command-0ad6gw2a.js} +6 -6
- package/{context.command-j9frz0dz.js → context.command-0qx447mb.js} +17 -16
- package/context.runner-1vsjztjg.js +25 -0
- package/{dependencyScanner-06z2dmrr.js → dependencyScanner-3afj2vt1.js} +1 -1
- package/{guideline.command-1epw6hey.js → guideline.command-cv5bmrx2.js} +2 -2
- package/guidelines/conventions/conventions.instruction.md +450 -1084
- package/guidelines/cssRule/cssRule.instruction.md +16 -1
- package/guidelines/fieldRule/fieldRule.instruction.md +57 -0
- package/guidelines/framework/framework.instruction.md +4 -7
- package/guidelines/mcpRule/mcpRule.instruction.md +11 -8
- package/guidelines/modelConstant/modelConstant.instruction.md +2 -1
- package/guidelines/modelStore/modelStore.instruction.md +9 -0
- package/guidelines/pageRule/pageRule.instruction.md +80 -0
- package/guidelines/queryRule/queryRule.instruction.md +35 -8
- package/guidelines/runtimeRule/runtimeRule.instruction.md +89 -2
- package/guidelines/ssrRule/ssrRule.instruction.md +13 -0
- package/guidelines/transportRule/transportRule.instruction.md +76 -8
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +4 -4
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +49 -4
- package/incrementalBuilder.proc.js +4 -4
- package/{index-jw6st2rd.js → index-192ha4xh.js} +356 -307
- package/{index-1hkr7mrb.js → index-1p98exm2.js} +2 -2
- package/{index-8byzcvxp.js → index-1xj2t7cb.js} +7 -7
- package/{index-er3hekv9.js → index-20bxxyvn.js} +80 -80
- package/{index-xe3x2kw5.js → index-35hc95h5.js} +1 -1
- package/{index-p5nrc5he.js → index-4mhy6qme.js} +4 -4
- package/{index-0qc5nd61.js → index-5edzv8fn.js} +5 -5
- package/{index-k9rrten6.js → index-5nd1wtkv.js} +92 -94
- package/index-62md4dwy.js +260 -0
- package/{index-z3v7abrk.js → index-665zp9rs.js} +38 -15
- package/{index-1t2xw0p0.js → index-6xn2n442.js} +4 -4
- package/{index-jg3fj8h0.js → index-86jt2a0e.js} +1 -1
- package/{index-k33me5sp.js → index-8fp3b47h.js} +419 -418
- package/{index-shastb7m.js → index-9z426msb.js} +17 -12
- package/{index-gdybjw3x.js → index-awbdfcf2.js} +4 -4
- package/{index-3hx1z96c.js → index-bqbf654f.js} +1 -1
- package/{index-jppthsff.js → index-cpb3nvma.js} +57 -58
- package/{index-b60ccq2s.js → index-d47ypebv.js} +17 -15
- package/{index-gc05vqd2.js → index-d72hfmdm.js} +13 -13
- package/{index-akha197v.js → index-fmzcs8v6.js} +4 -4
- package/{index-28ftaz3d.js → index-fyq1z6kt.js} +29 -27
- package/{index-vwmv64ad.js → index-g1mbppa9.js} +238 -236
- package/{index-hedt951c.js → index-h47cf1gg.js} +7 -7
- package/{index-hfqs9jvc.js → index-j4mkhz93.js} +3 -3
- package/{index-08a5ga6n.js → index-jw1zm273.js} +28 -30
- package/{index-q958tqqg.js → index-m9jj544v.js} +1 -1
- package/{index-8935h3n1.js → index-mdjc93e1.js} +1 -1
- package/{index-2wyn17fa.js → index-pydbnfez.js} +2 -2
- package/{index-bjh4wbtk.js → index-qwa5k1j8.js} +1 -1
- package/{index-81a1ywp6.js → index-tamvyvj3.js} +3 -3
- package/{index-dez4t5sd.js → index-trpasv9q.js} +260 -259
- package/{index-2ahj896a.js → index-tvehn2sp.js} +1 -1
- package/{index-2k0t8p3p.js → index-va06nr10.js} +1086 -1075
- package/{index-9c3cd767.js → index-vk0pa0mn.js} +20 -21
- package/{index-qyaqzh8e.js → index-w44cy4vs.js} +2216 -2216
- package/index.js +22 -22
- package/{library.command-n58dgakb.js → library.command-erp2f56n.js} +3 -3
- package/{localRegistry.command-f2768sww.js → localRegistry.command-xxpqgxjw.js} +15 -14
- package/{module.command-xzjg5k7n.js → module.command-gs2r615e.js} +5 -5
- package/{package.command-24sh1j1q.js → package.command-7343f74r.js} +4 -4
- package/package.json +2 -2
- package/{page.command-fqnm88na.js → page.command-4h4a8pnh.js} +3 -3
- package/{pipeline.command-vn6taz75.js → pipeline.command-a2nrk26m.js} +3 -3
- package/{primitive.command-4j1g1a0d.js → primitive.command-w6t0asqz.js} +6 -6
- package/{projectTypecheck-3y7ayty0.js → projectTypecheck-f534enqj.js} +1 -1
- package/{quality.command-5hqmcdwt.js → quality.command-77x7vbbx.js} +4 -4
- package/{repair.command-rw5tk460.js → repair.command-y6sstev7.js} +5 -5
- package/{scalar.command-0jpap45m.js → scalar.command-20b8cy0k.js} +4 -4
- package/{subspace.command-y7n9sghk.js → subspace.command-92xkc0s0.js} +11 -10
- package/templates/lib/cnst.ts +0 -1
- package/templates/workspaceRoot/.gitignore.template +24 -53
- package/{tunnel.command-5gjwh1f8.js → tunnel.command-2kw8vt9g.js} +8 -7
- package/{workflow.command-3t22f7eb.js → workflow.command-2djwmpqf.js} +10 -10
- package/{workspace.command-cq5xg2jq.js → workspace.command-5se6spqb.js} +28 -27
- package/TunnelShare-p8ymkww8.js +0 -13
- package/cloud.script-jc3hc5d9.js +0 -23
- package/context.runner-v0def5my.js +0 -24
- package/index-n68jx79w.js +0 -120
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
## Repo Overview
|
|
2
2
|
|
|
3
|
-
- Akan.js is a full-stack TypeScript framework
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- `libs/*` contains shared domain and utility libraries.
|
|
11
|
-
- `pkgs/akanjs/*` contains framework, runtime, and tooling facets published through the single `akanjs` package. Prefer extending existing Akan facets before adding new framework-level patterns.
|
|
12
|
-
- `infra/` contains Helm, deployment templates, edge configs, and Jenkins env/secret scripts.
|
|
3
|
+
- Akan.js is a full-stack TypeScript framework: write one line and deploy across web, app, server, database, and
|
|
4
|
+
infrastructure. It abstracts the technical implementation so what remains is business code — minimal, fast, and
|
|
5
|
+
type-safe everywhere at once.
|
|
6
|
+
- This is a Bun-first monorepo: `apps/<app>` (pages, domain code, UI, env files, `akan.config.ts`), `libs/*` (shared
|
|
7
|
+
domain and utility libraries), `pkgs/akanjs/*` (framework, runtime, and tooling facets published through the single
|
|
8
|
+
`akanjs` package — extend an existing facet before adding a framework-level pattern), and `infra/` (Helm,
|
|
9
|
+
deployment templates, edge configs, Jenkins env/secret scripts).
|
|
13
10
|
|
|
14
11
|
## Change Scope
|
|
15
12
|
|
|
@@ -28,1014 +25,447 @@ you fetch on demand — `get_guideline` with the name, or `akan guideline show <
|
|
|
28
25
|
| name | covers |
|
|
29
26
|
|---|---|
|
|
30
27
|
| `ssrRule` | server-share targets, the `akan.ssr.*` warnings, the client-boundary playbook |
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
28
|
+
| `pageRule` | route files, the route chain, heads, root layouts in a CSR bundle, lib pages |
|
|
29
|
+
| `runtimeRule` | web surfaces, gateway vs solo, the dev server, logging, the image, native apps and a desktop server, database modes |
|
|
30
|
+
| `queryRule` | slices and hydration, filter methods, soft removal and the facade's writes, full-text search, cascade |
|
|
31
|
+
| `transportRule` | guards and writing one, deadlines, cache, rate limits, server-to-server calls, bytes, binary pubsub, verbs |
|
|
32
|
+
| `mcpRule` | MCP exposure, configuration, wire behaviour, OAuth, page prompts, MCP Apps |
|
|
33
|
+
| `agentRule` | the in-page agent: chat options, tools, forms, zones, slash commands, compaction |
|
|
34
|
+
| `fieldRule` | field types, `hidden` / `secret` / `visual`, immutable and `Date` fields, image and file fields |
|
|
35
|
+
| `cssRule` | the semantic token set, the color vocabulary lint enforces, lib-owned tokens |
|
|
36
|
+
| `componentRule` / `recipeRule` | `_overrides.tsx` slots and drop-in components / authoring and consuming recipes |
|
|
37
|
+
| `workspaceRecipes` | worked recipes (fields, injection and `adapt()`, slices, endpoints, internals, insights), the generated API |
|
|
38
|
+
|
|
39
|
+
Module structure and each module file have their own guide (`moduleOverview`, `modelConstant` … `modelZone`,
|
|
40
|
+
`scalarConstant`, `scalarDictionary`, `scalarModule`).
|
|
40
41
|
|
|
41
42
|
## Lint-Enforced Rules (These Break The Build)
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
back.
|
|
46
|
-
|
|
47
|
-
- **Never hand-order Tailwind classes
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
`
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
`
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
and
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- **Never
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
- **Never
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
`
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
- **
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
and a `
|
|
112
|
-
-
|
|
113
|
-
|
|
114
|
-
`XInput` (or a scalar), `X`, or `XInsight`. The Object class is recognised by its `*Object` name and the Light
|
|
115
|
-
class by its `Light*` name or its `[...] as const` field tuple.
|
|
116
|
-
- **Never redeclare a generated CRUD endpoint name** in `*.signal.ts` (`no-redeclare-predefined-endpoint.grit`).
|
|
117
|
-
- **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** (`no-model-type-in-util-zone.grit`). Those two
|
|
118
|
-
roles are always client components, so a `cnst.Banner` / `cnst.LightBanner` prop is a class instance the server
|
|
119
|
-
has to hand across the boundary; take `bannerId: string` and read the model from the store instead. Only prop
|
|
120
|
-
positions are read, and only shapes that are actually an instance. Exempt: an indexed enum access
|
|
121
|
-
(`cnst.<Enum>["value"]`), a `ClientInit` / `ClientView` / `ClientEdit` or `ModelsProps<…>` type argument, a
|
|
122
|
-
function-typed prop, and any `cnst` type that never leaves the file. Other indexed accesses
|
|
123
|
-
(`ModelProps<"setting", cnst.LightSetting>`, `cnst.Banner["image"]`) stay flagged.
|
|
124
|
-
- **Never wrap a form setter in a pass-through arrow** (`no-unpublished-form-setter.grit`).
|
|
125
|
-
`onChange={(type) => st.do.setTypeOnTicket(type)}` runs identically to `onChange={st.do.setTypeOnTicket}`, but
|
|
126
|
-
the arrow is a fresh anonymous closure, so the control emits no `data-akan-action` and publishes no agent tool
|
|
127
|
-
for the field — a silent failure in two lines that read the same. A wrapper that transforms the value, adds a
|
|
128
|
-
statement, or writes a nested path with `writeOnX` stays legal; publish that one with an explicit `st.tool`.
|
|
129
|
-
- **No deep imports past a barrel** (`no-deep-internal-import.grit`). Cross-module constant references such as
|
|
130
|
-
`../map/map.constant` are the sanctioned exception.
|
|
131
|
-
- **Never import across the client/server boundary.** Client files (`ui/`, `webkit/`, `page/`, `*.store.ts`, every
|
|
132
|
-
`.tsx`) may not import a `*.document.ts` / `*.dictionary.ts` / `*.service.ts` / `*.signal.ts`, `srvkit/`, a
|
|
133
|
-
package `server` entrypoint, or the `db` / `srv` / `sig` / `dict` / `option` / `useServer` barrels
|
|
134
|
-
(`no-import-server-in-client.grit`). Server files (those four suffixes plus `srvkit/`) may not import a
|
|
135
|
-
`*.store.ts`, a module component, `ui/`, `webkit/`, a package `client` entrypoint, or the `st` / `store` /
|
|
136
|
-
`useClient` barrels (`no-import-client-in-server.grit`). Shared files — `common/` and `*.constant.ts` — are held
|
|
137
|
-
to **both**, so they reach neither side. `import type` is erased before bundling and stays legal in every
|
|
138
|
-
direction; a mixed value-and-type import is not exempt. Scoped to `apps/**` and `libs/**`.
|
|
139
|
-
- **Server-component discipline** is enforced on `page/**`, `*.Unit.tsx`, and `*.View.tsx`
|
|
140
|
-
(`no-import-client-functions.grit`, `no-use-client-in-server.grit`, `non-scalar-props-restricted.grit`).
|
|
141
|
-
- **Never write an async component outside `page/`** (`no-async-component-in-ui.grit`, scoped to
|
|
142
|
-
`{apps,libs}/**/ui/**/*.tsx`). React has no async client component, so a `ui/` component that awaits breaks as
|
|
143
|
-
soon as a client parent renders it, and the load drops below the route, which could have started it before the
|
|
144
|
-
first byte. Await in the page — or hand an unawaited `fetch.*` field to a `Zone` as an `init` / `view` prop, or
|
|
145
|
-
to a `<Load.Stream of={…}>` that awaits it behind a boundary of its own — and take the resolved data as a prop.
|
|
146
|
-
Only a PascalCase binding whose own initializer is `async` is matched, so an async handler inside a synchronous
|
|
147
|
-
component and `lazy(async () => import(…))` are untouched.
|
|
148
|
-
- **Never call `fetch.init*` from a client file** (`no-init-fetch-in-client.grit`). `fetch.init<Model><Suffix>` and
|
|
149
|
-
`fetch.get<Model>Init<Suffix>` compose the slice's list and insight queries into the hydration snapshot that
|
|
150
|
-
`Load.Units` / `Load.View` seed the store from. From a route it resolves before the first byte; after hydration
|
|
151
|
-
it is two extra round-trips for a shell the browser already painted. Load it in the page and pass the result
|
|
152
|
-
down as an `init` prop — or hand the unawaited `x<Model>Init<Suffix>` promise across — and reload from the client
|
|
153
|
-
through the generated `st.do.init<Model><Suffix>()`. Gated on the real `"use client"` directive plus
|
|
154
|
-
`*.store.ts`, and matched by shape (`init` + `Capitalize<refName>` + `Capitalize<suffix>`), so a hand-written
|
|
155
|
-
`initPayment` is out.
|
|
156
|
-
- `noArrayIndexKey` and `useExhaustiveDependencies` are **off** on purpose: `key={idx}` for embedded scalars and
|
|
157
|
-
short dependency arrays are intentional, not oversights.
|
|
158
|
-
- **A grit plugin diagnostic is suppressed as `lint/plugin`, not `plugin`** — `// biome-ignore lint/plugin: <reason>`
|
|
159
|
-
for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form
|
|
160
|
-
Biome's own category name suggests does nothing. The module-convention plugins apply to `apps/**` and `libs/**`
|
|
161
|
-
only, so a plain package under `pkgs/` never needs the escape hatch.
|
|
162
|
-
- **`biome.json` is strict JSON — a comment in it breaks config resolution.** Biome does not report the parse
|
|
163
|
-
error; it falls back to discovery and aborts on whatever nested config the walk finds. Rename the file to
|
|
164
|
-
`biome.jsonc` to document a disabled rule.
|
|
165
|
-
- **`akan lint` prints up to 200 diagnostics** (`--max-diagnostics <n>`, `0` for no limit). Biome's own default is
|
|
166
|
-
20 with no count, which reads as progress when the mix of findings merely changed.
|
|
44
|
+
`biome.json` extends `@akanjs/devkit/biome.base.json`, which scopes every grit plugin in `@akanjs/devkit/lint/`;
|
|
45
|
+
each plugin's header and diagnostic carry its full reasoning. Several rules produce output that looks wrong — do
|
|
46
|
+
not "fix" it back.
|
|
47
|
+
|
|
48
|
+
- **Never hand-order Tailwind classes** — `nursery/useSortedClasses` sorts them, `cn()` arguments included, and its
|
|
49
|
+
output (`font-bold text-2xl text-foreground`) is correct.
|
|
50
|
+
- **Stay inside the color vocabulary**: no raw palette (`bg-blue-500`), arbitrary or interpolated color classes
|
|
51
|
+
(`bg-[#3b82f6]`), daisyUI classes or slots (`btn-primary`, `bg-base-100`), or color literals in `style={{}}` —
|
|
52
|
+
they render no CSS. Use semantic tokens (`bg-primary`, `text-foreground/70`); a legitimate fixed color takes
|
|
53
|
+
`// biome-ignore lint/plugin: <reason>`. Slot replacements, SVG / `el.style` warnings, legal values: `cssRule`.
|
|
54
|
+
- **Never construct a raw `Error`.** Throw `new Err("<module>.error.<key>")` with the key registered as `[en, ko]` in
|
|
55
|
+
the module dictionary's `.error({})` — imported from `"../dict"` on the server, from `"@libs/<lib>/client"` /
|
|
56
|
+
`"@apps/<app>/client"` in UI. A `throw` is an error; a raw `Error` rejected, returned or stored loses the key and
|
|
57
|
+
status too (a warning for now). Exempt: tests, `*.constant.ts`, a root `native/` folder (plugins throw
|
|
58
|
+
`AkanNativeError`), and `common/**` / `env/**`, which have no legal `Err` import — keep throwing code out of them.
|
|
59
|
+
- **Never import a third-party package** from `page/**`, any barrel, a module logic file
|
|
60
|
+
(`*.{constant,dictionary,document,service,signal,store}.ts`) or module component
|
|
61
|
+
(`*.{Template,Unit,Util,View,Zone}.tsx`) — re-export it through a lib first (one-line shims in a lib's `common/`,
|
|
62
|
+
`webkit/`, `ui/` are load-bearing). `*.signal.test.ts` / `*.service.test.ts` too, though they may import `node:*`
|
|
63
|
+
and `bun:*`.
|
|
64
|
+
- **`#private` is banned in exactly `*.constant.ts`, `*.document.ts`, `*.service.ts`, `*.store.ts`** (by path, so it
|
|
65
|
+
stays the house style everywhere else, `srvkit/` and `adapt()` classes included).
|
|
66
|
+
- **No `console.log` / `console.debug`** (only `assert`, `error`, `info`, `warn`; server code uses `this.logger` or
|
|
67
|
+
`new Logger("ClassName")`), and **never `logger.log()` / `Logger.log()`** — it emits at `info` while reading like
|
|
68
|
+
its own level; write `.info()`. The ladder is `trace verbose debug info warn error`.
|
|
69
|
+
- **No `//!` or `/*!` in browser-reachable code** (`ui/`, `webkit/`, `common/`, `page/**/*.tsx`, `*.constant.ts`,
|
|
70
|
+
`*.store.ts`, module components) — Bun keeps both through minification. Use `// FIXME:`.
|
|
71
|
+
- **`{name}`, never `{{name}}`**, in a dictionary `.error()` / `.translate()` entry — only single braces interpolate.
|
|
72
|
+
- **Every endpoint and named slice names its guards** (warning) — a `query` / `mutation` / `pubsub` / `message`, a
|
|
73
|
+
named slice's `init()`, or a `slice()` map without `root` that names none (or `guards: []`) answers every caller.
|
|
74
|
+
Write `guards: [Public]` when anonymous access is the intent.
|
|
75
|
+
- **Never touch `document.cookie`, `localStorage` or `sessionStorage`** — a native shell's page keeps no cookies on
|
|
76
|
+
iOS, macOS and Linux, and Web Storage throws during SSR. Use `getCookie` / `setCookie` / `removeCookie`,
|
|
77
|
+
`storage`, `secretStorage` from `akanjs/client`. Browser APIs a WebView lacks (`navigator.share`, `serviceWorker`,
|
|
78
|
+
`Notification`, `geolocation`, `vibrate`) live in a `webkit/` hook behind `isNativeApp()` (warning). Both in full:
|
|
79
|
+
`runtimeRule`.
|
|
80
|
+
- **Never return a value from a store action** — actions dispatch through `st.do.*` as `void`; write state with
|
|
81
|
+
`this.set({ ... })`. A bare `return;`, a nested callback's `return`, a getter, and a `static` helper are fine.
|
|
82
|
+
- **No `static` on an `XObject` or `LightX` class** — `via()` mixes in their prototypes only; use `XInput` (or a
|
|
83
|
+
scalar), `X`, or `XInsight`.
|
|
84
|
+
- **Never redeclare a generated CRUD endpoint name** in `*.signal.ts`.
|
|
85
|
+
- **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** — the server would hand a class instance to a
|
|
86
|
+
client component; take `bannerId: string` and read the model from the store. Exempt: `cnst.<Enum>["value"]`, a
|
|
87
|
+
`ClientInit` / `ClientView` / `ClientEdit` / `ModelsProps<…>` type argument, a function-typed prop, a `cnst` type
|
|
88
|
+
that never leaves the file; `ModelProps<…>` and other indexed accesses stay flagged.
|
|
89
|
+
- **Never wrap a form setter in a pass-through arrow** — `(v) => st.do.setTypeOnTicket(v)` emits no
|
|
90
|
+
`data-akan-action` and publishes no agent tool. A wrapper that transforms, adds a statement, or calls `writeOnX`
|
|
91
|
+
is legal; publish it with an explicit `st.tool`.
|
|
92
|
+
- **No deep imports past a barrel**, except cross-module constants (`../map/map.constant`).
|
|
93
|
+
- **Never import across the client/server boundary** (`apps/**`, `libs/**`). Client files (`ui/`, `webkit/`,
|
|
94
|
+
`page/`, `*.store.ts`, every `.tsx`) never import a `*.document|dictionary|service|signal.ts`, `srvkit/`, a
|
|
95
|
+
package `server` entrypoint, or the `db` / `srv` / `sig` / `dict` / `option` / `useServer` barrels. Server files
|
|
96
|
+
(those four suffixes, `srvkit/`) never import a `*.store.ts`, a module component, `ui/`, `webkit/`, a package
|
|
97
|
+
`client` entrypoint, or the `st` / `store` / `useClient` barrels. `common/` and `*.constant.ts` obey both.
|
|
98
|
+
`import type` is legal everywhere; a mixed value-and-type import is not.
|
|
99
|
+
- **Server-component discipline** on `page/**`, `*.Unit.tsx`, `*.View.tsx` (`no-import-client-functions`,
|
|
100
|
+
`no-use-client-in-server`, `non-scalar-props-restricted`).
|
|
101
|
+
- **No async component under `ui/`** — React has no async client component. Await in the page, or hand an
|
|
102
|
+
unawaited `fetch.*` field to a `Zone` / `<Load.Stream of={…}>`, and take the data as a prop. An async handler in a
|
|
103
|
+
synchronous component and `lazy(async () => import(…))` are untouched.
|
|
104
|
+
- **Never call `fetch.init<Model><Suffix>` / `fetch.get<Model>Init<Suffix>` from a client file** (`"use client"` or
|
|
105
|
+
`*.store.ts`) — it is the hydration snapshot. Load it in the page and pass `init` down; reload with
|
|
106
|
+
`st.do.init<Model><Suffix>()`.
|
|
107
|
+
- `noArrayIndexKey` and `useExhaustiveDependencies` are **off** on purpose — `key={idx}` for embedded scalars and
|
|
108
|
+
short dependency arrays are intentional.
|
|
109
|
+
- **Suppress a grit diagnostic as `lint/plugin`** (`// biome-ignore lint/plugin: <reason>`,
|
|
110
|
+
`// biome-ignore-all lint/plugin: <reason>`); bare `plugin:` does nothing. `pkgs/` is out of scope.
|
|
111
|
+
- **`biome.json` is strict JSON** — a comment breaks config resolution without an error (Biome falls back to
|
|
112
|
+
discovery and aborts on a nested config); rename it `biome.jsonc` to document a disabled rule.
|
|
113
|
+
- **`akan lint` prints up to 200 diagnostics** (`--max-diagnostics <n>`, `0` = all); Biome's own uncounted 20 reads
|
|
114
|
+
as progress when the mix merely changed.
|
|
167
115
|
|
|
168
116
|
## Coding Style (`**/*.{ts,tsx}`)
|
|
169
117
|
|
|
170
|
-
-
|
|
171
|
-
|
|
172
|
-
-
|
|
173
|
-
-
|
|
174
|
-
|
|
175
|
-
-
|
|
176
|
-
|
|
177
|
-
-
|
|
178
|
-
-
|
|
179
|
-
|
|
118
|
+
- Run a large unit of work through a class instance; prefer class or `static` methods over unrelated top-level
|
|
119
|
+
helpers, and keep a class file's functions and variables inside the class when they reasonably can be.
|
|
120
|
+
- `#private` over TypeScript `private`, except in the four lint-banned suffixes, which use `private`.
|
|
121
|
+
- `const` function expressions over `function` declarations, unless hoisting, overloads, generators, or a framework
|
|
122
|
+
convention make `function` the better fit.
|
|
123
|
+
- One class per file, named after it (`RouteClientBuilder.ts`, not `buildRouteClient.ts`). Migrate callers off an
|
|
124
|
+
exported function that only instantiates a class and calls one method.
|
|
125
|
+
- TypeScript filenames are camelCase, except React component and convention files.
|
|
126
|
+
- One-off `className` strings stay inline; extract a constant only when reused, conditionally composed, or too large
|
|
127
|
+
to read in JSX.
|
|
180
128
|
|
|
181
129
|
### File Size And Duplication
|
|
182
130
|
|
|
183
|
-
- Keep files small
|
|
184
|
-
|
|
185
|
-
-
|
|
186
|
-
|
|
131
|
+
- Keep files small (house median well under 50 lines); split a component before ~150 lines rather than adding
|
|
132
|
+
section comments.
|
|
133
|
+
- Ship every scaffold file even when empty — `export class XInternal extends internal(srv.x, () => ({})) {}`, empty
|
|
134
|
+
dictionary stages, the `// state` / `// action` markers. They mark where things go.
|
|
135
|
+
- Never add a sibling helper file inside `lib/<model>/`; helpers go to `common/`, `webkit/`, `srvkit/`, or `ui/`.
|
|
136
|
+
- Prefer duplication to premature abstraction at the leaf: near-identical sibling modules and per-vendor pages are
|
|
137
|
+
copied with changed literals, not parameterised; share enums by import only.
|
|
187
138
|
|
|
188
139
|
### TypeScript Shape
|
|
189
140
|
|
|
190
|
-
-
|
|
191
|
-
-
|
|
192
|
-
|
|
193
|
-
-
|
|
194
|
-
|
|
195
|
-
-
|
|
141
|
+
- `interface` for object shapes, `type` only for unions and aliases.
|
|
142
|
+
- `interface <ComponentName>Props` sits directly above the component (no blank line), `className?: string` first,
|
|
143
|
+
named for the component (`CardProps`), never the model; exported only when a sibling imports it.
|
|
144
|
+
- No non-null assertions — narrow with `?.`, an early return, or a type predicate
|
|
145
|
+
(`.filter((id): id is string => !!id)`). Escape with `as unknown as T`, never `as any`.
|
|
146
|
+
- Never annotate a component's return type; annotate a helper only for a union, tuple, or type predicate.
|
|
147
|
+
- `as const` on every `enumOf(...)` array (even though `enumOf` infers its literals, so a typo already fails to
|
|
148
|
+
compile), `via(Model, [...] as const, …)` Light tuple, and module-scope lookup map; never the `enum` keyword.
|
|
196
149
|
- Async functions carry no `Async` suffix.
|
|
197
150
|
|
|
198
151
|
### Test Code
|
|
199
152
|
|
|
200
|
-
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
-
|
|
207
|
-
|
|
208
|
-
-
|
|
209
|
-
(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
- `lib/user/user.signal.spec.ts
|
|
216
|
-
- A placeholder `it` with a descriptive title is an acceptable floor. Write a real suite when the behaviour is security-relevant.
|
|
153
|
+
- Bun's runner (`describe`, `expect`, `test` from `bun:test`); colocated `*.test.ts` / `*.spec.ts`; focused behavior
|
|
154
|
+
tests of public contracts and edge cases. A placeholder `it` with a descriptive title is the floor; a
|
|
155
|
+
security-relevant behaviour gets a real suite.
|
|
156
|
+
- Run `bun run akan test <pkg>` from the root, or `cd <pkg> && bun test --isolate`. Plain `bun test` shares one
|
|
157
|
+
global object across files and fails dozens of tests from that pollution (`bunfig.toml` `[test] isolate` is not
|
|
158
|
+
honored as of Bun 1.3); from the repo root it breaks subprocess stdio pipes.
|
|
159
|
+
- Signal tests split in two: `<model>.signal.spec.ts` holds fixtures on `sampleOf(cnst.XInput)` with explicit
|
|
160
|
+
`Promise<cnst.X>` returns and **no assertions**; `<model>.signal.test.ts` holds `describe("<Model> Signal")`,
|
|
161
|
+
describe-scope `let` fixtures, one `beforeAll`, story-ordered `it`s, negatives via
|
|
162
|
+
`await expect(p).rejects.toThrow()`. Each `fetch.clone()` reports its own address (`198.18.x.y`), so an `ip` rate
|
|
163
|
+
limit counts it apart: clone once per simulated user, signup included (`clone({ clientIp })` names one).
|
|
164
|
+
- **One suite per module file**: `<model>.<role>.test.ts` beside the file it covers — never topic splits
|
|
165
|
+
(`chatMessage.trim.signal.test.ts`); add a `describe`. A suite spanning modules goes in `srvkit/`
|
|
166
|
+
(`libs/shared/srvkit/guards.signal.test.ts`), never the `lib/` root; `akan sync` refuses all three shapes. Only
|
|
167
|
+
`akan typecheck <app-or-lib>` typechecks tests (`bun:test` types included).
|
|
168
|
+
- Import `UserAgent` / `AdminAgent` from `lib/user/user.signal.spec.ts`, the one place agent types are re-typed.
|
|
217
169
|
|
|
218
170
|
## Comments
|
|
219
171
|
|
|
220
172
|
Do not narrate code. Do document the thing the code cannot say. Both halves are the rule.
|
|
221
173
|
|
|
222
|
-
- Never
|
|
223
|
-
|
|
224
|
-
-
|
|
225
|
-
|
|
226
|
-
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
5. `//?` — an explanatory aside
|
|
235
|
-
6. `//*` — a design note
|
|
236
|
-
7. Deletion caution — warn why removing a line or block would break something non-obvious
|
|
237
|
-
- Keep allowed comments one short line when possible.
|
|
238
|
-
- Every suppression carries a reason: `// biome-ignore lint/<rule>: <why>`. Never a bare disable block.
|
|
239
|
-
- Match nearby file style: if the surrounding code has few comments, keep it that way.
|
|
174
|
+
- Never restate the identifier, the signature, or the control flow; prefer names and structure. No JSDoc, section
|
|
175
|
+
banners, or "why/how" comments on ordinary logic.
|
|
176
|
+
- Density tracks the layer: pages carry none, product `lib/` and `ui/` stay under 1 %, `srvkit/` adapters and
|
|
177
|
+
`guards.ts` (what would leak without each guard) carry what the external constraints require.
|
|
178
|
+
- Warranted for: a vendor or protocol quirk; an infrastructure constraint; a library gotcha; security reasoning; why
|
|
179
|
+
an arbitrary-looking rule is correct; a math derivation; a domain field's business meaning; a state transition above
|
|
180
|
+
a document chain method; why an obvious alternative was rejected.
|
|
181
|
+
- Markers: `TODO` (tracked unfinished work), `FIXME` (known broken), `XXX` (a hazard a reader must not miss), `//!`
|
|
182
|
+
(disabled or must-fix — server, `srvkit/`, CLI only), `//?` (an aside), `//*` (a design note), and a deletion
|
|
183
|
+
caution saying why removing a line would break something non-obvious.
|
|
184
|
+
- One short line each. Every suppression carries a reason (`// biome-ignore lint/<rule>: <why>`), never a bare
|
|
185
|
+
disable block. Match the nearby density.
|
|
240
186
|
|
|
241
187
|
## TypeScript And Imports (`**/*.{ts,tsx}`)
|
|
242
188
|
|
|
243
|
-
-
|
|
244
|
-
|
|
245
|
-
-
|
|
246
|
-
|
|
247
|
-
-
|
|
248
|
-
|
|
249
|
-
- In `.tsx` files use one flat named import from the package client path (`import { cnst, fetch, st, Ticket, usePage } from "@apps/<app>/client"`) — never a relative `../` import.
|
|
189
|
+
- Bun and ESM per the root `tsconfig.json`. Path aliases across packages: `akanjs/*`, `@apps/*`, `@libs/*`,
|
|
190
|
+
`@contract/*`, respecting the `/client` and `/server` entrypoints. Let Biome organize imports.
|
|
191
|
+
- Backend `.ts` namespaces the generated barrels — `import * as cnst from "../cnst"`, `* as db`, `* as srv` —
|
|
192
|
+
with `import type * as srv` in services (keeps the runtime graph lazy) and a value import in signals.
|
|
193
|
+
- `.tsx` uses one flat named import from the package client path
|
|
194
|
+
(`import { cnst, fetch, st, Ticket, usePage } from "@apps/<app>/client"`), never a relative `../` import.
|
|
250
195
|
|
|
251
196
|
## Formatting And Linting
|
|
252
197
|
|
|
253
|
-
-
|
|
254
|
-
|
|
255
|
-
- Keep formatting consistent with `biome.json`: 2-space indentation, 120 line width, and double quotes for JS/TS.
|
|
256
|
-
- Avoid adding new `console` usage except accepted methods such as `console.error`, `console.info`, and `console.warn`.
|
|
257
|
-
- Do not make broad formatting-only changes in unrelated files.
|
|
198
|
+
- Biome formats and lints: `bun run akan lint <appName>` from the root; 2-space indent, 120 columns, double quotes.
|
|
199
|
+
No broad formatting-only changes in unrelated files.
|
|
258
200
|
|
|
259
201
|
## Client / Server Boundaries (`apps/**`, `libs/**`, `pkgs/akanjs/**`)
|
|
260
202
|
|
|
261
|
-
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
- Keep page props serializable unless the
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
- When unsure, inspect nearby files in the same app or package before introducing a new boundary pattern.
|
|
203
|
+
- `"use client";` tops client component files. It is mechanical by role: `Template`, `Zone`, `Util` always carry it
|
|
204
|
+
on line 1; `Unit`, `View` never do. `usePage()` is legal in server files.
|
|
205
|
+
- A `"use client"` file re-exports by name (`export { A, B } from "./x"`), never `export *` — the server knows a
|
|
206
|
+
client module only by the names it spells out, so `akan start` / `akan build` stop on a star re-export.
|
|
207
|
+
- Keep page props serializable unless the route pattern clearly allows otherwise; be careful importing client-only
|
|
208
|
+
code into pages and layouts. Preserve file roles (`.document.ts`, `.service.ts`, `.store.ts`, `.constant.ts`,
|
|
209
|
+
`.client.ts`), and inspect nearby files before introducing a new boundary pattern.
|
|
269
210
|
|
|
270
211
|
## SSR First — Server Rendering Is The Default
|
|
271
212
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
`
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
| Rule | Means |
|
|
289
|
-
|---|---|
|
|
290
|
-
| `akan.ssr.unnecessary-use-client` | The directive is there but nothing in the file needs it. Delete it. |
|
|
291
|
-
| `akan.ssr.client-static-component` | A component in a client file renders real markup with zero client-only capability — pure server work sitting in the bundle. |
|
|
292
|
-
| `akan.ssr.client-static-markup` | A large subtree wraps one or two interactive touches. Split it: interaction stays client, markup goes server. |
|
|
293
|
-
| `akan.ssr.client-mount-load` | A `useEffect(…, [])` loads server data. The route can fetch it before the first byte. |
|
|
294
|
-
| `akan.ssr.module-missing-server-view` | A module renders only from `Template`/`Zone`/`Util` and has no `Unit`/`View` at all. |
|
|
295
|
-
| `akan.ssr.template-client-state` | A `Template` holds form state in `useState` instead of the store. |
|
|
296
|
-
|
|
297
|
-
A third-party client package or an `index_.tsx` `lazy()` boundary is a legitimate reason for the directive and is
|
|
298
|
-
not flagged. Interaction-driven `fetch.*` (a lookup inside `onClick`) is not flagged either — only mount-time loads
|
|
299
|
-
are, because those are the ones the server could have done.
|
|
300
|
-
|
|
301
|
-
### Server-Side Implementation Playbook
|
|
302
|
-
|
|
303
|
-
1. **Wrap the interaction, not the UI.** The smallest useful client component adds one behaviour and renders
|
|
304
|
-
`children` untouched, so the markup inside never reaches the bundle.
|
|
305
|
-
2. **Split compound components so panels stay on the server.** `Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel` in
|
|
306
|
-
`akanjs/ui` is the shape: only the provider and menu hold state. Never one `"use client"` file with a mode
|
|
307
|
-
`useState` and every panel body inlined.
|
|
308
|
-
3. **Sync state instead of fetching it.** The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and passes the
|
|
309
|
-
result into a `Zone` as an `init` / `view` prop; `Load.Units` / `Load.View` hydrate the store from it. Never a
|
|
310
|
-
`useEffect(…, [])` that fetches on mount.
|
|
311
|
-
4. **Push the boundary down to the leaf that needs it.** A store-reading `Zone` should hold zero markup and
|
|
312
|
-
delegate to a server `View`.
|
|
313
|
-
5. **Hand each promise across, not the awaited value.** `fetch.init<Model><Suffix>` / `view<Model>` / `edit<Model>`
|
|
314
|
-
are awaitable *and* destructurable: `const { xInitInY, xListInY } = fetch.initXInY(id)` hands out one promise
|
|
315
|
-
per field with both queries already in flight. Give `xInitInY` to a `Zone` and `xListInY` to a
|
|
316
|
-
`<Load.Stream of={…}>`; each renders behind its own boundary as its own data lands, so the page never waits
|
|
317
|
-
for the slowest. `await` still returns the old shape and keeps the whole section in the shell — which is what
|
|
318
|
-
SEO snapshots, prerendering and pre-hydration E2E read, so await what the page needs immediately and stream
|
|
319
|
-
the rest. `xListInY` / `xInsightInY` hold hydrated model instances that React Flight refuses as client props:
|
|
320
|
-
consume them in a server component, never as a `Zone` prop.
|
|
321
|
-
6. **Use named `ReactNode` slots, not just `children`** — a client shell that takes one slot per region lets a
|
|
322
|
-
server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
|
|
323
|
-
is the shape: `title` / `left` / `right` beside `back` and `children`, each filled by the server.
|
|
324
|
-
7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
|
|
325
|
-
lookups belong in a module-scope `as const` map.
|
|
326
|
-
8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any of the page's
|
|
327
|
-
markup renders. After its fetch the redirect is an in-page one (HTTP 200); `.config({ ssr: "block" })` makes it a 3xx.
|
|
328
|
-
9. **Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants, or
|
|
329
|
-
`<details>`/`<summary>`, keeps both branches server-rendered.
|
|
330
|
-
10. **Keep the heavy island out of the first load** — behind the `ui/<Folder>/index_.tsx` + `lazy()` pair.
|
|
331
|
-
`usePage()` and `l()` work in server components, so translation never forces a boundary.
|
|
332
|
-
|
|
333
|
-
Full version with code, the `Tab` composition example, and a review checklist: `get_guideline` with `ssrRule`.
|
|
213
|
+
Server-rendered JSX ships as HTML and costs nothing to hydrate; `"use client"` ships it twice (markup plus JS that
|
|
214
|
+
re-runs). **The default is server; the directive is a cost justified per component** by a React hook, a JSX event
|
|
215
|
+
handler, the store, a browser global, or a client-only package — never by markup, params, `l()` or mapping data.
|
|
216
|
+
`akan quality ssr` (`--format json`) prints each app's and lib's server share: **50% is the floor, and a falling
|
|
217
|
+
share is a regression** to justify in the PR or revert. A client package or an `index_.tsx` `lazy()` boundary
|
|
218
|
+
justifies the directive; a `fetch.*` inside `onClick` is not flagged, only mount-time loads.
|
|
219
|
+
|
|
220
|
+
Playbook (warning table, code, checklist: `ssrRule`): ① wrap the interaction and render `children` untouched;
|
|
221
|
+
② split compound components (`Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel`) — never one client file with a mode
|
|
222
|
+
`useState` and every panel inlined; ③ fetch in the route and pass `init` / `view` to a `Zone`, never a
|
|
223
|
+
`useEffect(…, [])` load; ④ a store-reading `Zone` holds no markup and delegates to a server `View`; ⑤ hand each
|
|
224
|
+
promise across — `const { xInitInY, xListInY } = fetch.initXInY(id)`, `xInitInY` to a `Zone`, `xListInY` to
|
|
225
|
+
`<Load.Stream>` (model instances, server-only) — and `await` only what the shell must contain; ⑥ named `ReactNode`
|
|
226
|
+
slots (`Layout.Navbar`); ⑦ derived display logic on `Light<Model>`; ⑧ `getSelf({ unauthorize: "/signin" })` in
|
|
227
|
+
`_layout.tsx` (an in-page redirect after I/O; `.config({ ssr: "block" })` for a 3xx); ⑨ CSS (`data-*` +
|
|
228
|
+
`group-data-[…]`, `<details>`) over client state for visibility; ⑩ heavy islands behind `index_.tsx` + `lazy()`.
|
|
334
229
|
|
|
335
230
|
## Runtime & Build Configuration
|
|
336
231
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
a server, so `api: { prefix, websocketPrefix }` in `akan.config.ts` is what those follow; declare it there too
|
|
356
|
-
when the app ships either. Read the value with `getApiPrefix()` / `getWsPrefix()` from `akanjs/base` — never
|
|
357
|
-
write `/api` or `/ws` as a literal, and never try to hand it down as a React prop: `FetchClient` fixes its
|
|
358
|
-
origin before the first component renders. A blank prefix, a bare `/`, and one that shadows a basePath are all
|
|
359
|
-
refused.
|
|
360
|
-
- **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel** — the barrel re-exports
|
|
361
|
-
`AkanServer`, whose graph the gateway never runs. Keep entrypoint imports at the leaf.
|
|
362
|
-
- **Every `Logger` call builds a `LogRecord`** carrying `traceId`, `endpoint` and `origin` alongside the message,
|
|
363
|
-
so one request's lines share a trace. Request context is on by default, production included
|
|
364
|
-
(`AKAN_LOG_CONTEXT=0` opts out). **Give every sink a floor** — `Logger.addSink(sink, { minLevel })`; a sink with
|
|
365
|
-
none makes every `verbose` call render. **Never log per delivered record in anything that delivers records.**
|
|
366
|
-
Tail a running server with `akan logs <app>` (`--level --grep --endpoint --trace --child --role --origin
|
|
367
|
-
--since`, `--json`) or `.tail` / `.trace <id>` inside `akan console`.
|
|
368
|
-
- **`AKAN_LOG_FORMAT=ndjson` makes the container's stdout one JSON record per line, and the hub owner (gateway or
|
|
369
|
-
solo replica) its only writer** — every other server process turns its console off and forwards from
|
|
370
|
-
`AKAN_LOG_STDOUT_LEVEL` (default: the console level); a child's or the RSC worker's crash stack is wrapped as a
|
|
371
|
-
`raw=true` record instead of breaking the stream. Text stays the default; turn it on in the deployment.
|
|
372
|
-
`AKAN_LOG_STREAM_TOKEN` mounts `/_akan/app/logs` (SSE, `Last-Event-ID` resume, explicit `gap` events); unset,
|
|
373
|
-
the route does not exist. `AKAN_LOG_CANONICAL=1|slow` writes one `ok|error <endpoint> ms= status=` line per call;
|
|
374
|
-
`AKAN_LOG_FLIGHT=1` keeps each call's last 64 sub-level records and promotes them (`flight=true`) only when it
|
|
375
|
-
failed or ran past `AKAN_LOG_FLIGHT_MS`; `x-akan-debug` lowers one request to `trace` — in `local`, or with the
|
|
376
|
-
`AKAN_LOG_DEBUG_HEADER` secret. Structured values go in `LogRecord.attrs` via `Logger.emit(...)`; a key naming a
|
|
377
|
-
secret is `[redacted]` before the record exists.
|
|
378
|
-
- **`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
|
|
379
|
-
assembles one from; the string form takes no contributions. The generated image installs `ca-certificates` and
|
|
380
|
-
`tzdata` and nothing else, so an app needing `ffmpeg` or Chromium declares it in `preRuns` / `postRuns`. A lib
|
|
381
|
-
declares the steps its own runtime needs and every mounting app inherits them.
|
|
382
|
-
- **Native apps are the `native` section** — iOS, Android and a desktop app (macOS, Windows, Linux), each shipping
|
|
383
|
-
the CSR bundle on the native runtime. Shared fields (`appName`, `appId`, `version`, `indexPath`, `permissions`,
|
|
384
|
-
`plugins`, `deepLinks`, `updates`, …) sit beside one section per platform: `ios`, `android`, `desktop`, and a
|
|
385
|
-
platform section's own `indexPath` wins on that platform (`desktop: { indexPath: "/" }`). Without
|
|
386
|
-
`targets` the app has one target, `default`, and an app with no basePaths leaves `basePath` out; a target takes
|
|
387
|
-
the same shape and overrides the section field by field — objects merge key by key, a list or any other value
|
|
388
|
-
replaces the section's. `akan start-ios` / `start-android` / `start-desktop` run a dev build that loads its pages
|
|
389
|
-
from `akan start`; `build-ios` / `build-android` / `build-desktop` write
|
|
390
|
-
`dist/native/<app>/<target>/build/<platform>`, a desktop app only for the OS that builds it — `--arch` picks
|
|
391
|
-
a Windows or Linux app's CPU, and a macOS app is Apple silicon only. `--installer true` adds a Windows setup, a
|
|
392
|
-
macOS dmg or a Linux AppImage; `--sign` signs with the identity `AKAN_NATIVE_MACOS_*` / `AKAN_NATIVE_WINDOWS_*`
|
|
393
|
-
names and `--notarize` uploads the signed macOS app to Apple — the env alone does neither. With
|
|
394
|
-
`updates: { url, publicKeys }` an installed app takes the signed releases `akan publish-update` makes: 1 to 3 keys,
|
|
395
|
-
the online signing key first and offline spares after it (a key may revoke only those listed before it), of which a
|
|
396
|
-
desktop app trusts the first, or `publicKey` for one. `akan update-keygen` makes the key and prints it for
|
|
397
|
-
`publicKeys`.
|
|
398
|
-
- **A desktop app that carries its server (`desktop: { server: true }` in `native`, or in one target) gets nothing
|
|
399
|
-
from `docker`** — and `server: { omit: [...] }` leaves out packages only the image needs. That server is API-only, runs in database mode `single`, and listens on a loopback port any
|
|
400
|
-
program on the computer can call, so guard its endpoints as a network server's. An executable the app spawns
|
|
401
|
-
goes in `bin` — per platform, a download checked against its `sha256` or a file beside the config, put first on
|
|
402
|
-
the app's PATH so the server's `spawn("ffmpeg")` finds it, and in `ctx.binDir` for a native plugin — and a package
|
|
403
|
-
that builds itself at install goes in `trustedDependencies`, which the image honours too. Carry a static LGPL
|
|
404
|
-
ffmpeg: a `--enable-nonfree` build may not be redistributed. A file the user picks with
|
|
405
|
-
`filePicker.pickFiles({ forServer: true })` reaches it as a grant, never a copy or a path:
|
|
406
|
-
`NativeFile.resolve(grant, "read")` in `akanjs/server` asks the shell for it. What it carries is readable on the
|
|
407
|
-
user's computer — `private/` (each lib's too, under `private/libs/<lib>`), the one `env.server.<env>.ts` of its
|
|
408
|
-
`--env` and its libs' server env defaults (`env.server.testing.ts`) — and it has no `public/`: read runtime files
|
|
409
|
-
from `AKAN_APP_DIR`, never `process.cwd()`. **`server: { profile: "edge" }` bundles `env.server.<env>.edge.ts` in
|
|
410
|
-
that file's place**, and the build refuses one holding a secret-named value; `server: { modules: ["@edge"] }`
|
|
411
|
-
mounts only the modules whose `serve()` lists `operationMode: ["edge"]`, and what they reach.
|
|
412
|
-
- **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
|
|
413
|
-
trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
|
|
414
|
-
the `akan.config.ts` that owns the font, written against that scope's own `public/`.
|
|
415
|
-
- **Database modes are `database: { modes: [...] }`** — `single` (SQLite + Solid), `multiple` (one SQLite file on
|
|
416
|
-
a host volume + Redis), `cluster` (Postgres + Redis). The build carries the drivers of every declared mode and a
|
|
417
|
-
deployment picks one with `AKAN_DATABASE_MODE` — a declared one, named whenever the build declares several.
|
|
418
|
-
Where the data lives (`POSTGRES_URL`, `REDIS_URI`, `SQLITE_DATABASE_PATH`) is the deployment's env, ahead of
|
|
419
|
-
`env.server.ts`. `q.raw` is written in the dialect's own SQL, so an app running in two modes avoids it.
|
|
420
|
-
- **Backups run through the image's own `main.js`**: `bun main.js ops snapshot` / `ops restore` (offline), and
|
|
421
|
-
`/_akan/ops/*` for a control plane, mounted only when `AKAN_OPS_PUBLIC_KEY` is set and authenticated by a short
|
|
422
|
-
EdDSA token that key verifies. `GET /_akan/app/info` is public. `operationMode` gates none of it.
|
|
232
|
+
Declared in `akan.config.ts`, narrowed — never widened — by an env at boot; domain code reaches none of it
|
|
233
|
+
(`runtimeRule`).
|
|
234
|
+
|
|
235
|
+
- **`web: true | false | { csr: boolean }`** — `{ csr: false }` drops the CSR bundle native apps ship, `false` is
|
|
236
|
+
API-only.
|
|
237
|
+
- **Route prefixes move together**: `new AkanApp({ prefix, websocketPrefix })` in `main.ts`, mirrored in
|
|
238
|
+
`api: { … }` in `akan.config.ts` for a CSR shell or native bundle. Read them with `getApiPrefix()` /
|
|
239
|
+
`getWsPrefix()` (`akanjs/base`); never write `/api` or `/ws`, never pass one as a prop.
|
|
240
|
+
- **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`**, not the barrel.
|
|
241
|
+
- Give every log sink a floor (`Logger.addSink(sink, { minLevel })`), never log per delivered record in anything
|
|
242
|
+
that delivers records, and tail with `akan logs <app>`.
|
|
243
|
+
- **`docker` is `string | { image, preRuns, postRuns, command }`**; the generated image installs only
|
|
244
|
+
`ca-certificates` and `tzdata`.
|
|
245
|
+
- **Native apps are the `native` section** (iOS, Android, desktop). A desktop app's carried server listens on a
|
|
246
|
+
loopback port any local program can call — guard it as a network server — and ships `private/` and its env
|
|
247
|
+
readable on the user's computer; read runtime files from `AKAN_APP_DIR`.
|
|
248
|
+
- **`database: { modes: [...] }`** (`single`, `multiple`, `cluster`), chosen by `AKAN_DATABASE_MODE`; an app running
|
|
249
|
+
in two modes avoids `q.raw`.
|
|
423
250
|
|
|
424
251
|
## React Components And Styling (`**/*.tsx`)
|
|
425
252
|
|
|
426
|
-
-
|
|
427
|
-
|
|
428
|
-
- `
|
|
429
|
-
|
|
430
|
-
- Never hand-roll loading, empty, or list states
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
-
|
|
446
|
-
|
|
253
|
+
- `export const X = ({ … }: XProps) => { return (…); };` — `export default` only for pages, layouts, `lazy()`
|
|
254
|
+
targets. No `React.FC`, `defaultProps`, `PropsWithChildren`; defaults in the destructuring, `children: ReactNode`.
|
|
255
|
+
- `cond ? <X/> : null`, never `{cond && <X/>}` (renders `"false"` in a `className`); early `return null` only for
|
|
256
|
+
guard clauses.
|
|
257
|
+
- Never hand-roll loading, empty, or list states: `Load.Units` / `Load.View` / `Load.Edit` (`renderItem`,
|
|
258
|
+
`renderList`, `renderView`, `renderEmpty`), `<Empty />`, `Model.New` / `Model.Edit` / `Model.SureToRemove`.
|
|
259
|
+
Otherwise `<Load.Stream of={promise} fallback={…}>{(value) => …}</Load.Stream>` awaits one promise behind its own
|
|
260
|
+
boundary (a resolved value renders in the shell).
|
|
261
|
+
- Avoid hooks. `useState` only for modal-open, tab, draft-input, and drag state, never server data; `useEffect` only
|
|
262
|
+
for a genuine effect (subscribe-with-cleanup, one-shot init); `Tab` over a mode switch; zero `useState` in a
|
|
263
|
+
`.Template.tsx`.
|
|
264
|
+
- Forms are store-driven: `value={xForm.field}`, `onChange={st.do.setFieldOnX}` **by reference** — that emits the
|
|
265
|
+
`data-akan-action` / `data-akan-state` the in-page agent, E2E selectors and browser agents read. Always `Field.*`,
|
|
266
|
+
never a bare `<input>` for a model field; nested rows use `st.do.writeOnX("payments.3.name", v)` and the generated
|
|
267
|
+
`add<Field>OnX` / `sub<Field>OnX`. Never hand-write `data-akan-*`.
|
|
268
|
+
- Read with `st.use.*`, write with `st.do.*`; client components never call `fetch.*`.
|
|
269
|
+
- **Never persist form values yourself** — `Load.Edit`, `Model.EditModal` and `Model.New` save and recover the whole
|
|
270
|
+
`<model>Form` per record or route and user (`draft={false}` off, `draft="<scope>"` to name it; secret and hidden
|
|
271
|
+
values never saved; field `cache` / `cacheKey` props are deprecated no-ops — `modelStore`).
|
|
272
|
+
- Static class strings stay plain. `cn` (from `akanjs/client`) only for a conditional or to merge an incoming
|
|
273
|
+
`className`, caller last: `cn("base", cond && "extra", className)` — the only combiner: no `clsx`, raw `twMerge`,
|
|
274
|
+
or object syntax.
|
|
275
|
+
- Multi-slot components take named props (`wrapperClassName`), never a `classNames` object.
|
|
276
|
+
- Enum→class lookups: a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`, not
|
|
277
|
+
`Record<...>`. Shared by a second module, the classes become a recipe variant in `ui/Recipe/`; only an enum→variant
|
|
278
|
+
name map (`BadgeVariants["variant"]`) moves to `webkit/`.
|
|
279
|
+
- `<Link>` from `akanjs/ui` for internal navigation; `<a>` only for `mailto:` and external links.
|
|
447
280
|
|
|
448
281
|
## Naming And Language
|
|
449
282
|
|
|
450
|
-
- Component exports are role names
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
-
|
|
454
|
-
|
|
455
|
-
-
|
|
456
|
-
-
|
|
457
|
-
-
|
|
283
|
+
- Component exports are role names (`Card`, `General`, `Preview`, `Admin`, `Remove`) — the namespace carries the
|
|
284
|
+
model (`<Floor.Unit.Card>`, never `FloorCard`). `Util` exports are the endpoint verb minus the noun (`Serve`,
|
|
285
|
+
`Refund`).
|
|
286
|
+
- Layer the verbs: the document chain method drops the model (`sign()`), the service keeps the bare verb, signal,
|
|
287
|
+
store and dictionary re-add it (`signScContract`) — clear of generated CRUD, and `st.do.X` reads like `fetch.X`.
|
|
288
|
+
- Slice and filter names are prepositional (`inOrg`, `byStatuses`, `ofPortfolio`), never `getXInY` or `listX`.
|
|
289
|
+
- Handlers are `onX` props taking inline arrows, never an extracted `handleX`.
|
|
290
|
+
- Booleans `is*` / `has*` / `can*` / `show*` / `disable*`; counters `*Num`; indices `idx`; collections `*List` or
|
|
291
|
+
plural; no `SCREAMING_SNAKE` — module-scope tables are camelCase + `as const`.
|
|
292
|
+
- Keep existing domain vocabulary and its typos — transliterated terms and misspellings in use are load-bearing, and
|
|
293
|
+
renaming silently breaks callers that match on the name.
|
|
294
|
+
- Identifiers, type names, endpoint names, and log messages are **English**. Everything a user reads goes through
|
|
295
|
+
`l("model.field")` or `l.trans({ en, ko })` — never a hard-coded JSX string, never `window.alert`.
|
|
296
|
+
- Dictionary entries are `[en, ko]` pairs, nearly every label with a `.desc([en, ko])` even when repetitive; English
|
|
297
|
+
in Title Case, Korean the plain domain term, `.error()` Korean ending in `다.`.
|
|
458
298
|
|
|
459
299
|
## Domain Module Conventions (`apps/**/lib/**`, `libs/**/lib/**`)
|
|
460
300
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
(
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
backfill repeats safely, keeping the record's `createdAt`/`updatedAt` when asked.
|
|
502
|
-
|
|
503
|
-
**`<model>.service.ts`** — keep methods to a few lines: load → chain → `return await ….save()`. Write `return await`
|
|
504
|
-
explicitly in tail position; do not "optimize" it away. Side effects belong in `override async _preUpdate` /
|
|
505
|
-
`_postCreate`, not inline. Fire-and-forget is explicitly `void`-ed. Order deliberately: load every referenced document,
|
|
506
|
-
then save, then notify. Return `null` / `false` for "not allowed" or "not found" and let the signal decide whether that
|
|
507
|
-
is an error.
|
|
508
|
-
|
|
509
|
-
**`<model>.signal.ts`** — `XInternal` → `XSlice` → `XEndpoint`, all three declared even when empty. `exec` is a one-liner
|
|
510
|
-
delegating to the service.
|
|
511
|
-
|
|
512
|
-
**`<model>.store.ts`** — write a custom action only for a toast, an optimistic update, or a multi-field write; most
|
|
513
|
-
stores need none, because state and CRUD actions are generated. The body is three lines: `await fetch.X` →
|
|
514
|
-
`this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
|
|
515
|
-
`this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
|
|
516
|
-
through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
|
|
517
|
-
nothing** (`no-return-in-store-action.grit`). **Reach another store through `this`:**
|
|
518
|
-
`import type { RootStore } from "../st"`, then `(this as unknown as RootStore).logout()` to call its action or
|
|
519
|
-
`(this as unknown as RootStore).set({ … })` / `.get()` to write or read its state. Every store is mixed into one root
|
|
520
|
-
at runtime, so the cast only tells the type what `this` already is. Keep it `import type` — `st.ts` imports every
|
|
521
|
-
store, so a value import is a cycle.
|
|
522
|
-
|
|
523
|
-
**`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
|
|
524
|
-
`.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
|
|
525
|
-
Name every argument in `.arg()`, including framework-supplied `skip` / `limit` / `sort`. Use `modelDictionary`,
|
|
526
|
-
`scalarDictionary`, or `serviceDictionary` to match the module kind.
|
|
527
|
-
|
|
528
|
-
**`<module>.abstract.md`** — a title line, one declarative sentence naming what the module owns, a `## Rules` list of
|
|
529
|
-
two to five invariants the code cannot show, and an optional workflow arrow chain
|
|
530
|
-
(`draft -> signed -> active -> completed`). Never restate field lists or types. Update it whenever an invariant or
|
|
531
|
-
workflow changes.
|
|
301
|
+
A business concept is one `lib/` folder holding its logic files
|
|
302
|
+
(`<model>.constant|dictionary|document|service|signal|store.ts`) and components
|
|
303
|
+
(`<Model>.Template|Unit|View|Zone|Util.tsx`). `constant`, `dictionary` and
|
|
304
|
+
`signal` are shared contracts with no platform-specific dependency; persistence and queries live in `document`,
|
|
305
|
+
orchestration in `service`, client state in `store`; `Template` is forms, `Unit` list/card items, `View` details,
|
|
306
|
+
`Zone` page sections, `Util` domain UI helpers.
|
|
307
|
+
|
|
308
|
+
- **`<model>.constant.ts`** — `enumOf("camelName", [...] as const)` classes, then
|
|
309
|
+
`XInput → XObject → LightX → X → XInsight`, one blank line apart, `XInsight` even when empty. **Display and
|
|
310
|
+
predicate logic goes on `LightX`** (`isNew()`, `canWrite(user?)`), which server and client both hold, never a util
|
|
311
|
+
module — the most commonly missed rule. Collection helpers are `static` on `X`; a non-obvious field gets a short
|
|
312
|
+
trailing comment. `Date` fields are prototype accessors that a spread drops — copy with `new cnst.X().set(user)`
|
|
313
|
+
(`fieldRule`).
|
|
314
|
+
- **`<model>.document.ts`** — `XFilter extends from(...)` → `X extends by(...)` → `XModel extends into(...)`,
|
|
315
|
+
`sort: {}` always present. Chain methods validate → mutate → `return this`, never `save()` (the caller saves, so
|
|
316
|
+
`org.removeUser(id).removeInvite(id).save()` composes), with a one-line transition comment above each. Atomic
|
|
317
|
+
counters live on the Model class with the updater callback, returning `!!modifiedCount`; indexes and derived
|
|
318
|
+
totals go in `static override _onSchema`. **Removal is always soft** and reads see live rows unless
|
|
319
|
+
`{ withRemoved: true }`. `updateById` / `removeById` fire no hooks — a cascading or `_postRemove` model removes
|
|
320
|
+
through `remove<Model>(id)`, and `pickAndWrite` writes through the save hooks (`queryRule`).
|
|
321
|
+
- **`<model>.service.ts`** — a few lines: load → chain → `return await ….save()` (keep the `return await`). Side
|
|
322
|
+
effects go in `override async _preUpdate` / `_postCreate`; fire-and-forget is `void`-ed. Load every referenced
|
|
323
|
+
document, then save, then notify. Return `null` / `false` for not allowed or not found; the signal decides
|
|
324
|
+
whether that is an error.
|
|
325
|
+
- **`<model>.signal.ts`** — `XInternal` → `XSlice` → `XEndpoint`, all three even when empty; `exec` is a one-liner
|
|
326
|
+
calling the service.
|
|
327
|
+
- **`<model>.store.ts`** — custom actions only for a toast, an optimistic update, or a multi-field write:
|
|
328
|
+
`await fetch.X` → `this.setX(...)` → toast, or mutate the client model, `void fetch.*`, then commit.
|
|
329
|
+
`this.pick(...)` when the value must exist, `this.get()` when it may not, `this.set({...})` to write; lists through
|
|
330
|
+
the collection API (`xList.set(x).save()`), not spread. **Another store is reached through `this`**:
|
|
331
|
+
`import type { RootStore } from "../st"` (type-only — `st.ts` imports every store), then
|
|
332
|
+
`(this as unknown as RootStore).logout()` or `.set({ … })` / `.get()`; every store is mixed into one root at
|
|
333
|
+
runtime, so the cast only names what `this` already is.
|
|
334
|
+
- **`<model>.dictionary.ts`** — empty stages still written:
|
|
335
|
+
`.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`;
|
|
336
|
+
every `.arg()` named, framework `skip` / `limit` / `sort` included; `modelDictionary` / `scalarDictionary` /
|
|
337
|
+
`serviceDictionary` by module kind.
|
|
338
|
+
- **`<module>.abstract.md`** — a title, one sentence naming what the module owns, a `## Rules` list of two to five
|
|
339
|
+
invariants the code cannot show, an optional workflow chain (`draft -> signed -> active`). Never restate fields or
|
|
340
|
+
types; update it when an invariant or workflow changes.
|
|
532
341
|
|
|
533
342
|
## Service And Signal Conventions (`*.{service,signal}.ts`, `server/**`)
|
|
534
343
|
|
|
535
|
-
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
`
|
|
539
|
-
`onFailed(job, error)` runs once a job has failed for good — its last attempt, or bullmq's stall limit — the dead
|
|
540
|
-
letter, on either queue.
|
|
541
|
-
- Use `Slice` for typed data views that feed client stores and zones; keep each slice focused on one purpose.
|
|
542
|
-
- Use `Endpoint` for query and mutation contracts exposed to callers.
|
|
543
|
-
- Connect external APIs or infrastructure through adapters, usually under `srvkit/`, and inject them into services instead of importing vendor clients directly into domain logic.
|
|
544
|
-
|
|
545
|
-
### Guards And Transports
|
|
546
|
-
|
|
547
|
-
Full contract — credential handshake, room revalidation, socket cleanup scoping: `get_guideline` with
|
|
548
|
-
`transportRule`, or `akan guideline show transportRule`.
|
|
549
|
-
|
|
550
|
-
- Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` instead of
|
|
551
|
-
branching on `getHttpContext()` / `getWebSocketContext()`, and keep them side-effect free and safe to re-run —
|
|
552
|
-
a pubsub room's guards are re-run whenever the socket's credential changes, each time on a fresh context, so a
|
|
553
|
-
guard may memoize per context without replaying the verdict it gave at subscribe.
|
|
554
|
-
- **Arguments are parsed after the `"account"` guards, and `"resource"` guards run after the parse**, whatever
|
|
555
|
-
order `guards: [...]` lists them in. A caller an account guard refuses never reaches an argument parser, so an
|
|
556
|
-
expensive scalar parser is not a pre-auth attack surface. A guard or middleware that reads an argument earlier
|
|
557
|
-
still gets it (parsed on first read). An unauthenticated call with a malformed argument answers 401/403, not 400.
|
|
558
|
-
- Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is
|
|
559
|
-
unguarded unless it declares its own `guards` in its signal option.
|
|
560
|
-
- **Never read the caller's IP off the socket or the request peer — take `.with(Ip)`** (`context.getClientIp()`
|
|
561
|
-
inside a guard or middleware). Behind a federation gateway, `ws.remoteAddress` and the child's own peer are the
|
|
562
|
-
*gateway* (`127.0.0.1`) for every caller and for the whole life of every socket. `Ip` reads what a proxy
|
|
563
|
-
recorded, is unwrapped from its `::ffff:` form, and is `null` rather than a placeholder when genuinely unknown.
|
|
564
|
-
- **A device or another server proves itself with a machine token, never a staff session.**
|
|
565
|
-
`MachineToken.issue({ kind, id, version })` (`akanjs/server`) signs one with `AKAN_MACHINE_PRIVATE_KEY` (made by
|
|
566
|
-
`akan machine-keygen <file>`), with no expiry; a server holding that key or `AKAN_MACHINE_PUBLIC_KEY` sets
|
|
567
|
-
`account.machine` from a verified one on HTTP and websocket alike, and only from one. Read it with `.with(Machine)`
|
|
568
|
-
or in a guard, and revoke by bumping the version the guard compares to the record's own.
|
|
569
|
-
- **Every socket carries a `socketId`, and only the framework mints one** — read it off the `Ws` internal arg
|
|
570
|
-
(`.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`). Never mint your own from `ws.data`: the room
|
|
571
|
-
bookkeeping keys on the framework's id and a second one fails to match it silently. It identifies a
|
|
572
|
-
**connection**, not a caller, so per-user state keys on the account.
|
|
573
|
-
- **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it**
|
|
574
|
-
— a room for a `pubsub` subscribe, the socket for a `message` handler. Cleanup that must happen either way
|
|
575
|
-
registers for both.
|
|
576
|
-
- **A call's deadline is the endpoint's `{ timeout: <ms> }`, and there is nowhere else to put one.** It bounds
|
|
577
|
-
both ends: the `Timeout` middleware — registered by default, and standing aside for every endpoint that
|
|
578
|
-
declares none — rejects the call with `base.error.gatewayTimeout`, and the same value is serialized to the
|
|
579
|
-
client as that call's request budget. Declared nowhere, a call takes the client's own default of **30
|
|
580
|
-
seconds**, so an endpoint whose real work runs longer (provisioning, a firmware flow, an external
|
|
581
|
-
orchestration) has to declare one or it dies mid-flight with a transport error that does not say whether the
|
|
582
|
-
server ever received it. A caller overrides it per call with `fetch.x(…, { timeout })` — `false` waits as long
|
|
583
|
-
as the runtime will — and an app moves its own default with `fetch.instance.setTimeout(ms)`. An upload is
|
|
584
|
-
never bounded. **Losing the race does not cancel the work**: the handler runs to completion with nobody
|
|
585
|
-
holding its result, so a deadline is an answer to the caller, not an undo.
|
|
586
|
-
- **A cached answer is the endpoint's `{ cache: <ms> }`, and only a `query` taking no internal argument may have
|
|
587
|
-
one.** It is a step of the call, not a middleware, and stands aside for every endpoint that declares nothing.
|
|
588
|
-
Internal arguments are how a call learns who is asking (`.with(Self)`), so an endpoint that has them answers
|
|
589
|
-
per caller and one shared entry would be one caller's answer handed to the next — that endpoint and every
|
|
590
|
-
`mutation` are named in the log and left uncached. The lookup runs after the guards, so a hit reaches only a
|
|
591
|
-
caller they admitted; what is cached is the handler's result, so `resolveReturn` still masks fields and resolves
|
|
592
|
-
relations per call; a cache backend that is down is warned about and the call runs uncached. A call runs: the
|
|
593
|
-
cross-site check and an `ip` rate limit, the body read, then the middleware chain `Logging → Timeout → <lib
|
|
594
|
-
middlewares>`, then an `account` rate limit → account guards → argument parse → resource guards → internal
|
|
595
|
-
arguments → cache → handler. There is no `Retry` middleware.
|
|
596
|
-
- **A rate limit is the endpoint's `{ rateLimit: { calls, windowMs?, by? } }`** — `windowMs` 60 000 unless named,
|
|
597
|
-
`by: "ip"` (counted before the body is read) or `"account"` (counted after the middlewares, before the guards;
|
|
598
|
-
a caller with no account counts by address). It answers 429 `base.error.tooManyRequests` with `{ seconds }` and
|
|
599
|
-
a `Retry-After` header. Counters share the cache across instances. An app tunes it in `option.ts` with
|
|
600
|
-
`option.setRateLimit({ budget, endpoints, accountKey })` — `budget` covers every endpoint that declares none,
|
|
601
|
-
`endpoints` overrides one by key (a lib's included, `false` exempts), `accountKey` names who `by: "account"`
|
|
602
|
-
counts — and `rateLimit: false` exempts an endpoint from the app's budget. `AKAN_RATE_LIMIT=off` turns every
|
|
603
|
-
budget off whatever the code says; budgets are off in a `local` environment unless `AKAN_RATE_LIMIT=on`. A
|
|
604
|
-
business key (a phone number, an email) is counted in a service with `plug(RateLimit)` and
|
|
605
|
-
`consume(key, { calls, windowMs })`, which no switch turns off. Callers whose address is unknown share one
|
|
606
|
-
budget, and a page's server-side fetches carry the visitor's address, not the server's.
|
|
607
|
-
|
|
608
|
-
### Authorization Defaults
|
|
344
|
+
- `serve(...)` services hold business operations. Signals hold contracts: `internal(...)` for init, interval, cron
|
|
345
|
+
and queue jobs (retries, dead letter: `workspaceRecipes` Recipe 5), `slice(...)` for focused data views feeding
|
|
346
|
+
stores and zones, `endpoint(...)` for queries and mutations. External APIs and infrastructure go through
|
|
347
|
+
`srvkit/` adapters injected into services, never vendor clients in domain logic.
|
|
609
348
|
|
|
610
|
-
|
|
611
|
-
- **A generated verb is taken off HTTP with `guards: { cru: false }`** (or `get`/`create`/`update`/`remove:
|
|
612
|
-
false`), and a verb whose guards resolve to none is not mounted at all. `mcp: { cru: false }` does **not** do
|
|
613
|
-
this: it keeps the verbs off the agent shelf while HTTP serves them to whoever passes their guards.
|
|
614
|
-
- **Every endpoint that declares no guards is named in a boot `warn`**, since it answers anyone over HTTP and
|
|
615
|
-
WebSocket — a named slice's `init()`, a root slice, a `pubsub`/`message`/`query`/`mutation`. Write
|
|
616
|
-
`guards: [Public]` to keep one open on purpose.
|
|
617
|
-
- **Every custom `mutation` / `query` / `message` names its own `guards: [...]` array.** Never rely on the slice default. `Public` belongs on a slice `get:`; on a mutation it is reserved for an act that must work signed out — signing up or in, a token refresh, an OAuth protocol route — and such a mutation also declares a `rateLimit`. MCP refuses a mutation whose only guard is `Public`, so it never reaches an agent.
|
|
618
|
-
- **The guards are also the MCP exposure decision** — see MCP Exposure. An endpoint that names none is not published to agents at all, and a mutation whose only guard is `Public` is refused, so a missing `guards` array now costs visibility as well as authorization.
|
|
619
|
-
- Resource guards are `Can<Verb><Model>` classes in `srvkit/guards.ts` that `implements Guard` with an `async canPass(context)`. They **fail closed**: no resource named ⇒ `false`; a load that throws ⇒ `logger.warn` then `false`. Admin bypass goes first.
|
|
620
|
-
- Keep `static name = "User";` on guard classes. `fetch` serializes guard names and the API explorer filters on them; it looks like dead code, and deleting it breaks the UI. Comment it so the next reader knows.
|
|
621
|
-
- **Every guard class also declares `static scope: GuardScope`, and it is required with no default.** `"account"` means the verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`. An `"account"` guard that throws at listing time reached for arguments it does not have: the entry is hidden from every caller and the guard is named once in the log as mismarked. A guard that admits no model at all — `Person` — adds `static agents = false`; the MCP catalogue then refuses every endpoint it guards outright, so the person-only act is absent from the document rather than hidden per caller.
|
|
622
|
-
- The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`. Never trust a client-supplied id.
|
|
623
|
-
- Guards ship with the library that owns the model and are imported by its own signals through the package path, so a mounting app inherits authorization and cannot forget it.
|
|
624
|
-
- Services re-check ownership even when a guard already gated the call — two independent gates.
|
|
625
|
-
- `srvkit/guards.ts` earns real comments: explain what would leak without each guard.
|
|
626
|
-
|
|
627
|
-
### Signal Body Types
|
|
628
|
-
|
|
629
|
-
- `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
|
|
630
|
-
- Numbers must use `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
|
|
631
|
-
- `Upload` is valid only inside a mutation flagged for file upload: `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])`, as the `file` module does. It is not a model field type. The other body args of such a mutation arrive typed as in a JSON body. A file's `type` on the server is what its name implies, not its bytes — check the bytes when the type matters.
|
|
632
|
-
- Bytes are `Binary`, never `Any` — see Scalar & Field Type Reference. A `Binary` return travels as base64 in an
|
|
633
|
-
HTTP body, and a return declared `Any` that turns out to be bytes fails instead of reaching the caller as `{}`.
|
|
634
|
-
|
|
635
|
-
### Binary Pubsub And Mutation Verbs
|
|
636
|
-
|
|
637
|
-
Full contract — backpressure modes, the `Any`-carrying-bytes fallback, multi-verb paths: `get_guideline` with
|
|
638
|
-
`transportRule`.
|
|
639
|
-
|
|
640
|
-
- **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON envelope and the base64 a
|
|
641
|
-
JSON wire would need; the client's subscribe callback receives a `Uint8Array`. The optimization applies only
|
|
642
|
-
when the **whole** return is `Binary`. A declared `Binary` room **coalesces under backpressure** — keeping only
|
|
643
|
-
the newest frame — which is what telemetry and video want; declare `{ backpressure: "queue" }` when the frames
|
|
644
|
-
are a sequence a subscriber must see in full. A `pubsub(Any)` carrying bytes is framed too and warns once;
|
|
645
|
-
declare `Binary`.
|
|
646
|
-
- **A `mutation` is `POST`.** `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several
|
|
647
|
-
verbs; two endpoints claiming the same path **and** verb fail the boot. Reach for it only when a foreign wire
|
|
648
|
-
protocol forces the verb — Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
|
|
649
|
-
whatever is declared.
|
|
650
|
-
|
|
651
|
-
### Reserved Endpoint Names
|
|
652
|
-
|
|
653
|
-
- Auto-generated CRUD endpoints (e.g. `create<Model>`, `update<Model>`, `remove<Model>`) already exist for every model. Do not declare an `Endpoint`/`Slice` with a name that collides with them.
|
|
654
|
-
- The service layer surfaces such a collision as a typecheck error, but the signal layer can pass sync/typecheck/build and fail only at runtime — so treat name collisions as errors regardless of whether the build is green.
|
|
655
|
-
|
|
656
|
-
### Slices, Queries, and Hydration
|
|
657
|
-
|
|
658
|
-
Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `labelOf`, and the `q.exists` /
|
|
659
|
-
`q.missing` / `q.empty` split: `get_guideline` with `queryRule`, or `akan guideline show queryRule`.
|
|
660
|
-
|
|
661
|
-
- **The root slice (`""`, generated by `slice()`) takes `queryKey` and `args`, not a query.** `initX("byOwner",
|
|
662
|
-
[ownerId])` names one of the model's own filters and passes that filter's args; no key at all is the `any`
|
|
663
|
-
filter every model carries. A raw query descriptor could not survive the query string — `String(value)` sends
|
|
664
|
-
`[object Object]`. It is an admin API: `root:` is always `Admin`.
|
|
665
|
-
- A slice's `exec` returns a `QueryOf` (an opaque query descriptor); you **cannot** chain `.sort()`/`.limit()` on
|
|
666
|
-
it. Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })`. For a
|
|
667
|
-
chainable builder use the model facade's `find`/`findOne`.
|
|
668
|
-
- **A projection is bare on the facade and nested under `select` on a filter accessor.** `pickById(id, { secret:
|
|
669
|
-
true })` and `find`/`findOne`/`findById`/`pickOne` take the projection as their second argument; a generated
|
|
670
|
-
`findBy<Filter>(...args, { select: { secret: true } })` takes it inside the option object. The shapes do not swap
|
|
671
|
-
— `{ select: … }` handed to the facade projects a field named `select` and throws `Unknown document field path`.
|
|
672
|
-
The facade's third argument is the scope (`{ withRemoved: true }`), never a field. This is the only way to read a
|
|
673
|
-
`field.secret(...)` value.
|
|
674
|
-
- **`limit: 0` means every row on the server, never on the client.** A server `list<Filter>(…, { limit: 0 })`
|
|
675
|
-
reads without a ceiling, like an omitted limit, and `null` pages at 20. A client's `0` (`initX(…, { limit: 0 })`)
|
|
676
|
-
is served `MAX_PAGE_SIZE` (500) with a one-time warn. Paginate (`loadMoreOf<Model>`) or name a page size.
|
|
677
|
-
- **A slice list is either one window or an accumulated one.** `setPageOf<Model>` swaps the window;
|
|
678
|
-
`loadMoreOf<Model>()` appends the rows after the ones loaded and takes **no page number** — it skips by
|
|
679
|
-
`<model>List.length`, so the offset cannot drift from what is on screen when a live insertion moves the server's
|
|
680
|
-
baseline, and `pageOf<Model>` stays 1, which is what keeps live placement (first page only) working past the
|
|
681
|
-
first "more". `isCumulativeOf<Model>` is the mode.
|
|
682
|
-
- **"Is there more" is `hasMoreOf<Model>`, never `count > page * limit`.** It comes from the length of the batch
|
|
683
|
-
the server returned, so it survives `{ insight: false }` and cannot drift from a count a live event moved.
|
|
684
|
-
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`),
|
|
685
|
-
and so do client fetch results — `FetchClient` parses every return with `crystalize` defaulting to **true**
|
|
686
|
-
(`pkgs/akanjs/fetch/client/fetchClient.ts`). The raw `GetStateObject` shape is what the `<ref>Obj` /
|
|
687
|
-
`<ref>ObjList` / `<ref>ObjInsight` handles carry, because those pass `crystalize: false` on purpose so the
|
|
688
|
-
value can cross the RSC boundary as a client prop.
|
|
689
|
-
- Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
|
|
690
|
-
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
|
|
691
|
-
are query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
|
|
692
|
-
them on a model that carries no removal side effect; otherwise remove documents one at a time.
|
|
693
|
-
- **`update<Filter>` / `updateOne<Filter>` are chains, not calls:** `await updateInRoot(rootId).set({ status:
|
|
694
|
-
"archived" })`. The patch cannot trail the filter args, so it lands on a terminal `.set()`; building the chain
|
|
695
|
-
touches nothing.
|
|
696
|
-
- `removeOne` / `updateOne` hit the **newest** match, always, and report only counts — they are for "there is at
|
|
697
|
-
most one of these", not for claiming the next item off a queue. A condition in the query is a compare-and-set in
|
|
698
|
-
every mode: of two concurrent calls that both saw the row match, one applies, and `modifiedCount` says which.
|
|
699
|
-
- **A filter may not be keyed after its own model.** A filter `chat` on model `chat` would silently swap the
|
|
700
|
-
single-document `removeChat`/`updateChat` for a hookless query-level one; it throws at boot instead.
|
|
701
|
-
- **"Has no value" is `q.empty`, never `q.missing`.** `q.missing` is the key being *absent from the stored JSON*,
|
|
702
|
-
which a document written once and saved again no longer is. Reach for it only to find rows written before the
|
|
703
|
-
field was declared.
|
|
704
|
-
|
|
705
|
-
### Text Search In A Filter — `q.search()`
|
|
706
|
-
|
|
707
|
-
Full contract — relevance ordering, `columns` / `weights`: `get_guideline` with `queryRule`.
|
|
708
|
-
|
|
709
|
-
- Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
|
|
710
|
-
q.search(text, { prefix: true }))`. The generated `listBySearch` / `countBySearch` / `queryBySearch` /
|
|
711
|
-
`insightBySearch` come for free — you do **not** need a slice to make search usable.
|
|
712
|
-
- **Only add a search slice when the model's data is safe to enumerate.** A filter is server-side; a slice is a
|
|
713
|
-
client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk
|
|
714
|
-
the table. Leave that decision to the mounting app.
|
|
715
|
-
- `q.search()` compiles to a JOIN, not a WHERE fragment, so it **must sit at an AND position**. Nesting it under
|
|
716
|
-
`q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery`.
|
|
717
|
-
- Blank or whitespace-only input matches **nothing**. Never "fix" that into a passthrough: a passthrough turns a
|
|
718
|
-
search endpoint into a full listing.
|
|
719
|
-
- Order by relevance with the built-in `relevance` sort key. A slice endpoint never reaches the unspecified
|
|
720
|
-
fallback — leaving `sort` off gets `latest`, so a client wanting score order has to name `relevance`.
|
|
721
|
-
|
|
722
|
-
### Service / Signal Injection
|
|
723
|
-
|
|
724
|
-
- Injected dependencies resolve by field-name convention: a field named `<refName>Service` resolves to the service registered under `<refName>`, and `<refName>Signal` likewise (`pkgs/akanjs/service/injectInfo.ts`).
|
|
725
|
-
- The `Service`/`Signal` suffix is required — the injector strips it to derive the registry lookup key. Name the field after the target refName plus the suffix, not arbitrarily.
|
|
726
|
-
- Preference order inside a service: `service<srv.XService>()` for another module's service · `plug(AdapterClass)` or `plug(StorageAdaptorRole)` for an adapter · `use<T>()` only to reach an `option.ts`-registered legacy singleton · `env(...)` for config.
|
|
727
|
-
- **`memory(...)` takes a scalar or model class, not only a primitive** — `memory(Map, { of: CallStateInput })` serializes through the constant and travels as JSON text, so the Redis and sqlite-backed caches round-trip the same declaration. Never hand-encode JSON into a `String` memory. A memory is scoped to the service or adaptor that declares it; `ttl` (milliseconds) sets how long each write lives unless the `set` call names its own `expireAt`, and a `Map` entry expires on its own.
|
|
728
|
-
|
|
729
|
-
### Adapters — `adapt()` And `plug()`
|
|
730
|
-
|
|
731
|
-
An injected singleton is an `adapt()` class in `srvkit/`. Write new adapters this way.
|
|
732
|
-
|
|
733
|
-
```ts
|
|
734
|
-
export class AdminNoti extends adapt("adminNoti" as const, ({ use, env, plug, memory }) => ({
|
|
735
|
-
discordApi: use<DiscordApi>(),
|
|
736
|
-
workspaceRoot: env(() => `~/build/${getEnv().environment}`),
|
|
737
|
-
masterHost: plug(MasterHost),
|
|
738
|
-
tokenMap: memory(Map, { of: String }),
|
|
739
|
-
})) {}
|
|
740
|
-
```
|
|
349
|
+
### Authorization
|
|
741
350
|
|
|
742
|
-
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
-
|
|
754
|
-
|
|
755
|
-
-
|
|
351
|
+
- **Every `slice()` takes an explicit `{ guards: {…} }` second argument, and `root:` is always `Admin`.**
|
|
352
|
+
`guards: { cru: false }` (or per verb) takes a generated verb off HTTP; `mcp: { cru: false }` only keeps it off the
|
|
353
|
+
agent shelf.
|
|
354
|
+
- **Every custom `mutation` / `query` / `message` names its own `guards: [...]`** — the slice map reaches only
|
|
355
|
+
generated endpoints, and a `pubsub` / `message` without its own is unguarded. `Public` belongs on a slice `get:`;
|
|
356
|
+
on a mutation only for an act that must work signed out (sign-up/in, token refresh, an OAuth route), which also
|
|
357
|
+
declares a `rateLimit`. A guardless endpoint is warned at boot and never published to MCP, which also refuses a
|
|
358
|
+
mutation guarded only by `Public`.
|
|
359
|
+
- Guards serve HTTP and websocket alike: read `context.get<T>("account")`, never `getHttpContext()` /
|
|
360
|
+
`getWebSocketContext()`; keep them side-effect free and re-runnable. The acting user comes from `.with(Self)` /
|
|
361
|
+
`.with(CurrentUserId)` / `.with(Me)` (never a client-supplied id) and the caller's address from `.with(Ip)`.
|
|
362
|
+
- Keep `static name = "User";` on guard classes — `fetch` serializes it and the API explorer filters on it. It looks
|
|
363
|
+
dead, so comment it.
|
|
364
|
+
- **Every guard declares `static scope: GuardScope`, no default**: `"account"` reads only the caller (MCP listings
|
|
365
|
+
evaluate it to hide entries), `"resource"` needs the call's arguments and fails closed without them. `SignedIn` /
|
|
366
|
+
`Admin` / roles are `"account"`, every `Can<Verb><Model>` is `"resource"`; `Person` adds `static agents = false`.
|
|
367
|
+
- `Can<Verb><Model>` guards in `srvkit/guards.ts` fail closed and ship with the model's library (`transportRule`).
|
|
368
|
+
Services re-check ownership behind a guard — two independent gates.
|
|
369
|
+
|
|
370
|
+
### Calls, Queries, Agents (summaries)
|
|
371
|
+
|
|
372
|
+
- **Wire (`transportRule`)**: a deadline is the endpoint's `{ timeout: <ms> }` (undeclared, the client quits at
|
|
373
|
+
30 s; the handler is not cancelled); `{ cache }` only on a `query` with no internal argument; `{ rateLimit }` per
|
|
374
|
+
endpoint, `option.setRateLimit` per app, `plug(RateLimit)` for a business key. `socketId` names a connection, not
|
|
375
|
+
a caller. A device proves itself with a `MachineToken`. Server to server, `fetch.clone({ origin })` opens no socket
|
|
376
|
+
without `connect: true`, retries through `retry`, and rethrows a remote `Err` as is — never wrapped or re-keyed.
|
|
377
|
+
- **Body types**: `.body()` / `.param()` take scalars, model refs, or `enumOf(...)`; `Int` / `Float`, never
|
|
378
|
+
`Number` (`pkgs/akanjs/signal/endpointInfo.ts`); `Upload` only in a `fileUpload: true` mutation; bytes are
|
|
379
|
+
`Binary`, never `Any`. A model or scalar argument is checked against the request schema on every transport: a
|
|
380
|
+
wrong JSON type, a value outside its enum or a missing required field answers 400 with `data.name` naming the
|
|
381
|
+
path (`data.items.2.price`); a defaulted field may be left out, an undeclared key is dropped, and a stored model's
|
|
382
|
+
input keeps an absent field absent.
|
|
383
|
+
- **Reserved names**: generated CRUD (`create<Model>`, `update<Model>`, `remove<Model>`, …) exists for every model.
|
|
384
|
+
The service layer reports a collision as a typecheck error, but a colliding signal name can pass sync, typecheck
|
|
385
|
+
and build and fail at runtime — treat it as an error.
|
|
386
|
+
- **Queries (`queryRule`)**: the root slice takes `queryKey` + `args`, never a raw query. A slice `exec` returns a
|
|
387
|
+
`query<Filter>(…)` descriptor (never a `list<Filter>` array) and is paged through `initX(…, { sort, page, limit })`.
|
|
388
|
+
A projection is bare on the facade (`pickById(id, { secret: true })`), under `select` on a filter accessor — the
|
|
389
|
+
only way to read a `field.secret`. More rows: `loadMoreOf<Model>()` / `hasMoreOf<Model>`, never
|
|
390
|
+
`count > page * limit`. A filter's `remove*` / `update*` are hookless (no cascade). No value is `q.empty`, never
|
|
391
|
+
`q.missing`.
|
|
392
|
+
- **Search**: `q.search(text, { prefix: true })` in a filter, at an AND position, over `text`-role fields; a search
|
|
393
|
+
*slice* only on data safe to enumerate; blank input matches nothing — never a passthrough.
|
|
394
|
+
- **`cascade` names a direction; the wrong one loses data**: `removeRef` on the owner's relation, `removeWith` on
|
|
395
|
+
the child's reference to its owner. A query-level removal runs no cascade.
|
|
396
|
+
- **MCP (`mcpRule`)**: `/mcp` publishes every guarded endpoint by default; `mcp: false` (or a `slice()` `mcp` map
|
|
397
|
+
mirroring `guards`) is curation, not authorization. `guards: [Every, Person]` reserves an act for a person;
|
|
398
|
+
`.with(AgentCall)` narrows an agent's call. Settings are `option.setMcp` in `lib/option.ts`; `JWT_SECRET` is
|
|
399
|
+
required outside `local`. Never make a refusal message more helpful. Every entry needs a `.desc()`; publish a
|
|
400
|
+
screen with `page().prompt(name, description)`.
|
|
401
|
+
- **In-page agent (`agentRule`)**: mount `<Agent.Chat />` once in a layout; it never executes tools and refuses
|
|
402
|
+
every call until `option.setAgentAccess(<guard>)`; the LLM is `option.setLlm({ apiKey, model, host })`, never env.
|
|
403
|
+
The surface is what components declare — `st.tool(…).exec(fn)` beside its control, `st.use` / `st.sel` /
|
|
404
|
+
`st.ref` keys — never a store class. `st.use.x({ agent: false })` hides a key; its model's `hidden` / `secret` /
|
|
405
|
+
`visual` fields are stripped at read. A tool value is capped at 20,000 characters. Route guidance is
|
|
406
|
+
`<Agent.Guide instructions="…" />`; model-facing text is English.
|
|
407
|
+
|
|
408
|
+
### Injection And Adapters
|
|
409
|
+
|
|
410
|
+
- A field named `<refName>Service` / `<refName>Signal` resolves to that registration — the suffix is required
|
|
411
|
+
(`pkgs/akanjs/service/injectInfo.ts`). In a service: `service<srv.XService>()` · `plug(AdapterClass)` /
|
|
412
|
+
`plug(StorageAdaptorRole)` · `use<T>()` only for a legacy `option.ts` singleton · `env(...)` for config.
|
|
413
|
+
- `memory(...)` takes a scalar or model class (`memory(Map, { of: CallStateInput })`); never hand-encode JSON into a
|
|
414
|
+
`String` memory.
|
|
415
|
+
- **A new adapter is an `adapt()` singleton in `srvkit/`**, injected with `plug(TheClass)`, key `as const`. Never
|
|
416
|
+
register it in `lib/option.ts` (it self-registers); never construct a `Logger` in it (`this.logger` exists);
|
|
417
|
+
lifecycle work in `override async onInit()`. A per-use value object is a plain class. Legacy
|
|
418
|
+
`constructor(options)` adapters migrate only when already being changed. Example and adapter conventions (one
|
|
419
|
+
`#api<T>` with a timeout, pagination, secrets, parameter shapes): `workspaceRecipes` Recipe 2.
|
|
756
420
|
|
|
757
421
|
### Error Placement
|
|
758
422
|
|
|
759
|
-
- State-machine preconditions throw in `document.ts
|
|
760
|
-
|
|
761
|
-
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
-
|
|
768
|
-
|
|
769
|
-
with jitter until the call's one deadline runs out; a mutation retries under one `Idempotency-Key` it mints, or the
|
|
770
|
-
`idempotencyKey` you name, and the server runs a mutation once per key, caller and arguments, answering a repeat
|
|
771
|
-
with the first response as it was sent; a signed-out call, one whose handler returns a `Response`, and a call
|
|
772
|
-
through MCP run every time. A 4xx is never retried.
|
|
773
|
-
- `String(err)` and `${err}` read `Err: <key> {…data}`, the data as one line of JSON with secret-named keys masked,
|
|
774
|
-
so a ``logger.warn(`…: ${String(error)}`)`` keeps the exit code or status the `Err` carried.
|
|
775
|
-
- **A failure another process reported travels as itself.** A server-to-server `fetch.<endpoint>(..., { origin })` restores the remote `Err` — the same class, key and `data` — so a hop that rethrows it answers its own caller with that error and the dictionary translates it. Never wrap the catch in a `new Error`, and never re-key it as a `new Err` of your own: both discard what the endpoint chose to say, and a bare `Error` is generalized to `Internal Server Error` on the way out.
|
|
776
|
-
|
|
777
|
-
### MCP Exposure
|
|
778
|
-
|
|
779
|
-
Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
|
|
780
|
-
follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An
|
|
781
|
-
endpoint that declares a real guard is published; one that declares none is refused, and so is a mutation whose
|
|
782
|
-
only guard is `Public`. The guards are already the authorization decision, so a second switch would say nothing
|
|
783
|
-
they do not — while guaranteeing that every endpoint added later is invisible to agents until somebody remembers
|
|
784
|
-
it.
|
|
785
|
-
|
|
786
|
-
- **`mcp: false` keeps an endpoint off the shelf without touching its guards.** Guards answer "may an agent call
|
|
787
|
-
this"; they cannot answer "does this belong on a shelf", and a step of a UI-driven state machine
|
|
788
|
-
(`requestPhoneCodeForSignin`) is perfectly guarded and still a mistake for a model to reach. Write it in the
|
|
789
|
-
signal option (`mutation(Boolean, { guards: [Every], mcp: false })`, `init({ guards: [SignedIn], mcp: false })`),
|
|
790
|
-
or as an `mcp` map on `slice()` that **mirrors the `guards` map key for key** (`mcp: { cru: false }`) — root
|
|
791
|
-
slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, and it is
|
|
792
|
-
curation, not authorization: HTTP serves the endpoint exactly as before. To take generated CRUD off HTTP too,
|
|
793
|
-
write `guards: { cru: false }`.
|
|
794
|
-
- **Narrow by cost, and read the boot log first.** MCP forbids a `$ref` across entries, so every entry inlines the
|
|
795
|
-
schema of every model it mentions and the listing is re-sent whole to every agent that connects. By default a
|
|
796
|
-
response schema names a nested model instead of inlining it (`outputSchema: "shallow"`; `"full"` restores the
|
|
797
|
-
closure, `"none"` drops the schema and keeps the text block) and a request schema asks for a relation's id, which
|
|
798
|
-
is what the wire carries. `MCP catalogue: … · listing 214KB` plus a per-signal `MCP catalogue cost:` line says
|
|
799
|
-
where the rest went; `mcp: { cru: false }` on a model is the next lever. `outputSchema` moves wire bytes and client
|
|
800
|
-
memory only — the model receives names, descriptions and input schemas (measured: 219 tools ≈ 41k tokens either
|
|
801
|
-
way) — so tool count and the input model behind `create*` / `update*` are what cost context. In one line:
|
|
802
|
-
`Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost the model
|
|
803
|
-
tokens, `outputSchema` for nothing that reaches the model.
|
|
804
|
-
- Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
|
|
805
|
-
allowedOrigins, pageSize, language, outputSchema, rateLimit, apps, modern, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
|
|
806
|
-
`main.ts`**. Each field has an `AKAN_MCP_*` env spelling. `AKAN_MCP_READONLY=true` is the read-only-deployment
|
|
807
|
-
valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off, and an edge server
|
|
808
|
-
(`AKAN_PUBLIC_OPERATION_MODE=edge`) starts with it off unless `AKAN_MCP=true` or `setMcp(true)`. Every call that executes an
|
|
809
|
-
endpoint is rate-limited per caller (120 a minute, 8 in flight, per process; 429 + `Retry-After`) — `rateLimit`
|
|
810
|
-
/ `AKAN_MCP_RATE_LIMIT` tunes or disables it.
|
|
811
|
-
- **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
|
|
812
|
-
beside `/mcp` — `/.well-known/oauth-authorization-server`, `/oauth/authorize`, `/oauth/token`, `/oauth/register`
|
|
813
|
-
(RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
|
|
814
|
-
names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
|
|
815
|
-
`aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
|
|
816
|
-
judge it unchanged. The tokens this built-in server mints carry no scope; `auth.scopes` / `AKAN_MCP_SCOPES` are for
|
|
817
|
-
tokens an external issuer mints, and declared beside this server they refuse every one of its tokens with 403
|
|
818
|
-
`insufficient_scope`. Per-app knobs live in `env.server.*` under `oauth` (`consentPath` and
|
|
819
|
-
`signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `accessTokenSeconds`,
|
|
820
|
-
`clientIdMetadata`, `enabled`, plus `issuer` / `resource` — set those when a tunnel or an edge makes the derived
|
|
821
|
-
origin wrong, because an MCP client compares the issuer byte for byte). `/mcp` reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` (or
|
|
822
|
-
`security.jwtSecret`) is mandatory whenever `operationMode` is not `local`, because the derived fallback is
|
|
823
|
-
forgeable from three public names; `AKAN_ALLOW_DERIVED_JWT_SECRET=1` accepts that risk explicitly. A grant is revoked
|
|
824
|
-
whole — `POST /oauth/revoke` (RFC 7009) by the client, `fetch.revokeOAuthConnection(sessionId)` by the account's
|
|
825
|
-
owner, `fetch.listOAuthConnections()` to see them — and a revoked access token is refused at its next call.
|
|
826
|
-
- **A person may, a model may not.** An agent's token names its `client_id` and `aud`; a browser session has
|
|
827
|
-
neither, and `context.origin` is `"mcp"` for a call through the MCP endpoint. `guards: [Every, Person]` refuses a
|
|
828
|
-
model and, because `Person` declares `static agents = false`, takes the entry out of the MCP catalogue document
|
|
829
|
-
itself (named in the boot log like `mcp: false`); `.with(AgentCall)` hands an endpoint the same verdict as a
|
|
830
|
-
boolean to narrow what the call sets in motion; `isAgentCall(context)` is the function behind both
|
|
831
|
-
(`@libs/shared/srvkit`). Never sniff `aud` through a cast.
|
|
832
|
-
- **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
|
|
833
|
-
guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
|
|
834
|
-
generated `light<Model>` read. An `enumOf` argument is checked against its values on every path. **Every refusal
|
|
835
|
-
is named in the boot log**, along with every published entry missing a dictionary `.desc()` — an agent picks a
|
|
836
|
-
tool by its description.
|
|
837
|
-
- A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
|
|
838
|
-
generalized. Never make either message more helpful — the difference is what enumerates your private surface.
|
|
839
|
-
- **A `field.visual` field is stripped from every MCP result** and from the readable schema so the two agree.
|
|
840
|
-
- **A structured result ships twice by default** — as `structuredContent` and as the same JSON in the text block.
|
|
841
|
-
`option.setMcp({ legacyTextBlock: false })` leaves a pointer there instead.
|
|
842
|
-
- **A picture is an `ImageResult` return** (`akanjs/base`): `query(ImageResult, { guards })` returns `{ data,
|
|
843
|
-
mimeType, caption? }` — padded base64 with no `data:` prefix, and `image/png` / `jpeg` / `webp` / `gif`. Over MCP it
|
|
844
|
-
answers the caption as a text block and the picture as an image block, with no `structuredContent`; over HTTP it
|
|
845
|
-
is that JSON object. Like `Binary` it is never a model field.
|
|
846
|
-
- **A prompt is a screen: `page().prompt(name, description)`.** There is no `prompt()` on `endpoint()`.
|
|
847
|
-
`prompts/get` runs the page's body in the RSC worker under the caller's token — nothing is rendered — and answers
|
|
848
|
-
the description, one `resource` per `fetch.*` the page made (masked by the endpoint's return model, addressed by
|
|
849
|
-
its `akan://` uri, one document once whatever shapes read it) and the published tools of the modules it fetched
|
|
850
|
-
from, minus what is already attached; lists — only lists — are cut to `promptBudget`. A guard refusing a query
|
|
851
|
-
inside the body reads as the screen refusing the account, the same way a redirect does. A
|
|
852
|
-
required argument left out answers a pointer to the `<model>List…` tool, never a guess. The description is the
|
|
853
|
-
only instruction, in English — `<Agent.Guide>` never reaches a prompt, and the in-page chat lists no page prompts.
|
|
854
|
-
- **The same page is a tool too, and an MCP Apps host renders it.** A `.prompt()` page answers `tools/call` with
|
|
855
|
-
that briefing plus its web URL, and a host that declares the `io.modelcontextprotocol/ui` extension (claude.ai,
|
|
856
|
-
ChatGPT, VS Code) shows the screen itself in the chat — the CSR bundle in a sandboxed iframe whose every call the
|
|
857
|
-
host relays under the agent's token. It shows the page alone: every layout above it still runs (guards, providers,
|
|
858
|
-
`<Agent.Guide>`) and draws nothing, unless it draws no page, as a gate does. Nothing is written for the view. An
|
|
859
|
-
endpoint kept off the shelf only by `mcp: false`, and the generated `light<Model>` read, stay callable by that view
|
|
860
|
-
and are listed to no model; a `Person` act is still refused there. `option.setMcp({ apps: false })` keeps pages
|
|
861
|
-
prompts only, as a read-only deployment does, and a page named like an endpoint stays one (boot warns).
|
|
862
|
-
|
|
863
|
-
Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
|
|
864
|
-
`McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
|
|
865
|
-
|
|
866
|
-
## In-Page Agent
|
|
867
|
-
|
|
868
|
-
Every akan app can host a component-level agent that reads the rendered screen and drives it. **A component
|
|
869
|
-
declaration is the surface, exactly**: `st.tool` publishes one action, and `st.use` / `st.sel` / `st.ref` make one
|
|
870
|
-
store key readable while the reading component is mounted. Nothing is derived from a store class — a lever the
|
|
871
|
-
screen does not offer the user is not one an agent may pull in their place. The React core is the `use-agentic`
|
|
872
|
-
package; apps and libs never import it directly (`no-import-external-library`) — everything reaches them through
|
|
873
|
-
`st.*` and `akanjs/ui`.
|
|
874
|
-
|
|
875
|
-
Read the full contract before building an agent surface — the chat's own options, `st.tool` / `st.expose` /
|
|
876
|
-
`st.useState` in full, zones and namespaces, attachments and speech, slash commands, transcript compaction, and
|
|
877
|
-
the built-in tool semantics: `get_guideline` with `agentRule`, or `akan guideline show agentRule`. What follows is
|
|
878
|
-
what every developer must know even when not building one.
|
|
879
|
-
|
|
880
|
-
- **Mount `<Agent.Chat />` once in a layout.** The endpoint is a stateless relay that **never executes tools**:
|
|
881
|
-
every tool runs in the caller's own browser session, gated by guards and the approval card. Its guard is
|
|
882
|
-
`AgentRelayAccess`, which refuses every call until the app names a guard of its own —
|
|
883
|
-
`option.setAgentAccess(SignedIn)`. **The LLM is configured in `option.ts`, never through the environment**
|
|
884
|
-
(`option.setLlm({ apiKey, model, host })`). Two adaptors ship, one per wire: `OpenaiLlm` is the default and
|
|
885
|
-
speaks the chat-completions dialect to whatever `host` names — OpenAI, DeepSeek, Groq, OpenRouter, Ollama — and
|
|
886
|
-
`AnthropicLlm` speaks the Messages API and reads a PDF as well as a picture. Both require `model`. A provider
|
|
887
|
-
neither covers is an `adapt()` class in `srvkit/` implementing `LlmAdaptor`, applied with
|
|
888
|
-
`option.applyAdaptor(LlmAdaptorRole, TheClass)`; whatever settings it needs beyond `LlmOption` ride `setLlm`
|
|
889
|
-
too, read back with `use<MyLlmOption>()`.
|
|
890
|
-
- **Declare the tool beside the control that already does it**, never as a separate surface:
|
|
891
|
-
`st.tool("x").desc("…").arg("id", ID).exec(fn)` returns the callable to hand to `onClick` — one handler for the
|
|
892
|
-
person and the agent. Publish it only where the screen already renders the control; a falsy name declares the
|
|
893
|
-
tool without publishing it, and a `disabled` control publishes nothing.
|
|
894
|
-
- **Forms publish themselves**: a `Field.*` / `Input.*` / `Select` / `Switch` handed `onChange={st.do.setXOnY}`
|
|
895
|
-
**by reference** publishes that setter, and `st.use.yForm()` adds `fillYForm(patch)`. An inline arrow publishes
|
|
896
|
-
nothing — normalize with the control's `transform` prop, and multi-write with a `_postSet<Field>` store method.
|
|
897
|
-
This is the same rule `no-unpublished-form-setter.grit` enforces.
|
|
898
|
-
- **Reading is per key, not per store.** `st.use.x({ agent: false })` keeps a subscribed key off the surface; a key
|
|
899
|
-
no component reads is unreadable. A value with populated `hidden`/`secret` fields is refused at read.
|
|
900
|
-
- **Return what answers the question, not the record.** A tool's value is capped at 20,000 characters before it
|
|
901
|
-
enters the transcript, and once there it rides *every* later turn. A field that is bulky and useless to a model
|
|
902
|
-
is fixed once at the model instead: `field.visual(String)`.
|
|
903
|
-
- **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or page — the render
|
|
904
|
-
tree is the cascade. It is a component, not a `pageConfig` field, and `*.abstract.md` is never served to agents.
|
|
905
|
-
- **Model-facing text is English, always** — every `.desc()`, `instructions`, Guide text. The `l()` rule covers
|
|
906
|
-
strings a *user* reads.
|
|
423
|
+
- State-machine preconditions throw in `document.ts`, cross-document rules in `service.ts`, request policy in
|
|
424
|
+
`signal.ts` guards. Best-effort code returns a sentinel (`null`, `undefined`, `[0, 0]`, `{}`) — no Result types.
|
|
425
|
+
- An endpoint names the errors it throws on purpose — `errors: { 409: ["ticket.error.locked"] }` in its signal
|
|
426
|
+
option, with keys its dictionary defines and never `base.error.*`. That is the status's OpenAPI response and a line
|
|
427
|
+
of the MCP tool's description; outside production a thrown key it leaves out warns once. A caller narrows with
|
|
428
|
+
`Err.is(error, "ticket.error.locked")`.
|
|
429
|
+
- `try/catch` turns an exception into a decision: guards → `logger.warn` → `false`; adapters → `logger.error` →
|
|
430
|
+
`null`; UI `try/finally` resets a spinner. A bodyless `catch {}` needs a one-line reason.
|
|
431
|
+
- Store actions do not `try/catch` (the framework toasts the `Err`); client validation is `msg.error("<key>")` plus
|
|
432
|
+
an early return, never a throw.
|
|
907
433
|
|
|
908
434
|
## Scalar Modeling (`**/*.constant.ts`)
|
|
909
435
|
|
|
910
|
-
-
|
|
911
|
-
|
|
912
|
-
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
- Defaults
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
### Scalar & Field Type Reference
|
|
923
|
-
|
|
924
|
-
- **Import from `akanjs/base`** (real classes/helpers, not globals): `Int`, `Float`, `ID`, `Any`, `Binary`, `Upload`, `ImageResult`, `enumOf`, and the `dayjs` factory. There is **no `JSON` scalar** — use `Any` for open/flexible payloads.
|
|
925
|
-
- **Use the JS globals directly (no import needed)**: `String`, `Boolean`, `Date`. They are monkey-patched to behave like scalars, so `field(String)` typechecks.
|
|
926
|
-
- **`Number` is not a valid field/body type.** `NumberConstructor` is intentionally not augmented, so `field(Number)` / `.body("x", Number)` fails to typecheck. Use `Int` or `Float` instead.
|
|
927
|
-
- Runtime resolution of every scalar (globals included) goes through `PrimitiveRegistry` by `refName` (`pkgs/akanjs/base/primitiveRegistry.ts`).
|
|
928
|
-
- **`Binary` is raw bytes on the wire, and never a model field.** It is `Uint8Array` on both sides — a Node
|
|
929
|
-
`Buffer` is one, so a server handler may pass one straight in — and it accepts base64 in either direction, so
|
|
930
|
-
the same declaration serves a JSON body and a binary frame. It is **not storable**: the class build refuses
|
|
931
|
-
`field(Binary)` naming the `File` model instead, because every non-base field lives in the `_doc` JSON column
|
|
932
|
-
and bytes would sit there as base64 and ride every read of the row. Never send bytes as `Any` — `Any` passes a
|
|
933
|
-
`Buffer` through untouched and `JSON.stringify` then spells it `{ type: "Buffer", data: number[] }`, which
|
|
934
|
-
`JSON.parse` never restores; the result is 3.6x the wire and a shape that only breaks at the first byte-offset
|
|
935
|
-
read. MCP refuses a `Binary` return for the same reason it refuses `Any`.
|
|
936
|
-
|
|
937
|
-
### Text Search Fields — the `text` role
|
|
938
|
-
|
|
939
|
-
Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH_ENABLED`: `get_guideline` with
|
|
940
|
-
`queryRule`.
|
|
941
|
-
|
|
942
|
-
- A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and
|
|
943
|
-
likewise `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model
|
|
944
|
-
switch. Declaring roles is all the wiring there is.
|
|
945
|
-
- Pick the role by what the value *is*, because `bm25` weights them positionally (`title` 10, `tag` 3, `desc` 1,
|
|
946
|
-
`filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
|
|
947
|
-
scoping value (status, owner, role) that must be matchable but must never outrank a real title hit. `thumb` is
|
|
948
|
-
mirrored for rendering a hit and is **not** indexed — never expect it to match.
|
|
949
|
-
- `schema.index({ x: "text" })` (a Mongo text spec) builds nothing and warns at boot: full-text search is this role.
|
|
950
|
-
- **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
|
|
951
|
-
mirror is plaintext and an indexed secret would leak through search. The same refusal throws at runtime as a
|
|
952
|
-
backstop, including for a `text` field *underneath* one of those. Do not work around either.
|
|
953
|
-
- The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
|
|
954
|
-
indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
|
|
955
|
-
- Search runs in every database mode — fts5 on SQLite, a weighted `tsvector` (`pg_trgm` for `trigram`) on
|
|
956
|
-
Postgres. The same text matches the same documents; only the order may differ on Postgres.
|
|
957
|
-
|
|
958
|
-
### Image & File Fields
|
|
959
|
-
|
|
960
|
-
- **Do not declare `Upload` as a model field.** `Upload` is a signal-body-only primitive (see Service And Signal Conventions). Models reference the `File` model instead.
|
|
961
|
-
- Declare an image/file field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many.
|
|
962
|
-
- The store then auto-generates an `upload<Field>On<Model>(fileList)` action that calls the framework upload mutation and polls file status until it leaves `"uploading"` (`pkgs/akanjs/store/action.ts`).
|
|
963
|
-
- Storage is wired through the `StorageAdaptor` DI role (default `BlobStorage`, `pkgs/akanjs/service/predefinedAdaptor/storage.adaptor.ts`); the reference implementation is the `file` module. Do not hand-roll data-URL fallbacks.
|
|
964
|
-
|
|
965
|
-
### Cascade Remove — the `cascade` option
|
|
966
|
-
|
|
967
|
-
Full contract — bulk planning, the auto-created index, cycle cutting, boot-time validation: `get_guideline` with
|
|
968
|
-
`queryRule`.
|
|
969
|
-
|
|
970
|
-
**The value names the direction, and getting it wrong is a data loss.** The two actions can sit on the same field
|
|
971
|
-
shape, so `cascade` never means "related" — it means one of exactly these:
|
|
972
|
-
|
|
973
|
-
- `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
|
|
974
|
-
`image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it.
|
|
975
|
-
- `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
|
|
976
|
-
its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three
|
|
977
|
-
forms: a relation, an id with `ref`, or a polymorphic id with `refPath`. An array, a Map, `ref` together with
|
|
978
|
-
`refPath`, and a field naming no owner each fail the class build.
|
|
979
|
-
- **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time. The one
|
|
980
|
-
exception is opt-in and priced: `cascade: "removeWithAny"` takes a free-form `String` type field holding the
|
|
981
|
-
owner's refName, and sweeps for children on **every** model's removal. The sweep is one indexed probe, but a
|
|
982
|
-
single wildcard edge turns every cascade in the app back to one document at a time; the boot log names the
|
|
983
|
-
edges in one `info` line. The action carries its own `refPath` in the option type, so the widening cannot be
|
|
984
|
-
declared apart from the direction it widens.
|
|
985
|
-
- **A cascade goes through the target's service, never its model** — that path is what runs the target's
|
|
986
|
-
`_postRemove`, where a module puts the side effect the removal has to carry.
|
|
987
|
-
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
|
|
988
|
-
`origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
|
|
989
|
-
- Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
|
|
990
|
-
restorable, and reviving the owner does not revive what went with it.
|
|
991
|
-
- **Query-level removes fire no hooks and therefore no cascade** — `removeManyByQuery` / `updateManyByQuery`, the
|
|
992
|
-
generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
|
|
993
|
-
at a time when it cascades.
|
|
436
|
+
- Models use `via` from `akanjs/constant`, all five layers (`Input`, `Object`, `Light<Model>`, `<Model>`,
|
|
437
|
+
`<Model>Insight`). `Int` for counts, `Float` only for decimals, `ID` for references; structured fields over `Any`.
|
|
438
|
+
- Import `Int`, `Float`, `ID`, `Any`, `Binary`, `Upload`, `ImageResult`, `enumOf`, `dayjs` from `akanjs/base`;
|
|
439
|
+
`String` / `Boolean` / `Date` are globals. No `JSON` scalar (use `Any`), no `Number`, and `Binary` / `Upload` are
|
|
440
|
+
never fields — a file is `field(File).optional()` / `field([File])`, uploaded by the generated
|
|
441
|
+
`upload<Field>On<Model>`.
|
|
442
|
+
- Defaults: a literal for scalars, a thunk for anything constructed (`default: () => dayjs()`). Arrays
|
|
443
|
+
`field([T])`; optional `.optional()`.
|
|
444
|
+
- **`field.hidden` / `field.secret` read `null` on the client while the type declares them** — guard with `??` or
|
|
445
|
+
`== null`, never `=== undefined` or a default. `field.visual(T)` is rendered but stripped from every agent and MCP
|
|
446
|
+
read. Full contract: `fieldRule`.
|
|
994
447
|
|
|
995
448
|
## Akan Page Routing (`apps/**/page/**`)
|
|
996
449
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
-
|
|
1003
|
-
|
|
1004
|
-
`
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
-
|
|
1015
|
-
|
|
1016
|
-
stack a second screen beside the frame. Its components still mount and their effects run (`<Auth.User />` works).
|
|
1017
|
-
Anything a person must see from a root layout — an overlay, a gate, a banner — renders through
|
|
1018
|
-
`createPortal(…, document.body)`, as `<Agent.Chat />` and the dialogs already do. A non-root `_layout.tsx` has no
|
|
1019
|
-
such limit.
|
|
1020
|
-
- **A page names every `[x]` segment of its path with `.param("x", Type)`** and reads a query key only through
|
|
1021
|
-
`.search("k", Type)`. Values arrive typed — `ID`/`String` → string, `Int`/`Float` → number, `Boolean`, `Date` →
|
|
1022
|
-
Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
|
|
1023
|
-
search value that fails is dropped. A layout may declare a subset. Names are string literals: `akan sync` reads
|
|
1024
|
-
them off the source and refuses a `[projectId]` folder whose page declares no `.param("projectId")`. `lang` is
|
|
1025
|
-
never declared: every route sits under `/:lang`, and the locale segment reaches every stage as `lang`. On a web
|
|
1026
|
-
page's first render `st.use.searchParams()` is still the server's empty snapshot, synced right after hydration, so
|
|
1027
|
-
an effect that needs the query on its first run takes the page's `.search()` value as a prop.
|
|
1028
|
-
- `_overrides.tsx` is a logic-free UI-override manifest: imports plus a single `export default override({ Slot: AppComponent })` (from `akanjs/ui`), no `"use client"`. It re-skins framework `akanjs/ui` components for its route subtree; nested manifests merge over ancestors slot-by-slot (closest wins). See the UI Customization reference for the slot list.
|
|
1029
|
-
- The legacy shape — `export default async function Page` beside `pageConfig` / `head` / `Loading` exports — still
|
|
1030
|
-
loads and logs one deprecation warning per file at boot. Migrate with the `workspaceRecipes` recipe "Migrating
|
|
1031
|
-
`page/` to the route chain"; new files are never written in it.
|
|
1032
|
-
- `libs/<lib>/page` follows the same rules and ships routes to apps that opt in with `syncPageLibs` in `akan.config.ts`: `true` takes every lib dep that has a `page` folder, an array takes the libs listed, `false` (the default) syncs nothing.
|
|
1033
|
-
- `akan sync` links those routes into `apps/<app>/page/(libs)/(<lib>)` — once per basePath when the app declares subRoutes. The folder is generated and gitignored; edit the lib source, never the link.
|
|
1034
|
-
- Both path segments are route groups, so a lib route mounts at its own path (`libs/<lib>/page/login/_index.tsx` serves `/login`). Two synced routes that resolve to the same pattern are a sync-time error.
|
|
1035
|
-
- `.config({ devOnly: true })` keeps a route out of `akan build` while it keeps serving under `akan start` and keeps being typechecked. On a `_layout.tsx` it excludes every route under that directory too. Write it as a literal `true`/`false` — the build reads it off the source without evaluating the module.
|
|
1036
|
-
- Before changing route behavior, check `pkgs/akanjs/server/routeTreeBuilder.ts` and nearby routes for the expected pattern.
|
|
1037
|
-
|
|
1038
|
-
### Page Body Shape
|
|
450
|
+
Full contract — heads, CSR root layouts, typed params, lib pages, `devOnly`: `pageRule`.
|
|
451
|
+
|
|
452
|
+
- `page/` holds `.tsx` route modules only: `<routeName>.tsx` or `_index.tsx`, `_layout.tsx`, `_overrides.tsx` — no
|
|
453
|
+
other `_*.tsx`, helpers, `.ts` / `.js` files, or uppercase filenames. `[id]` is a dynamic segment, `(group)` a
|
|
454
|
+
route group.
|
|
455
|
+
- **A route file exports one chain** — `page()`, `layout()` in a `_layout.tsx`, `rootLayout()` in a root
|
|
456
|
+
`_layout.tsx` — and every setting is a stage (`.param`, `.search`, `.config`, `.head`, `.loading`; `.notFound` /
|
|
457
|
+
`.error` on layouts; `.fonts` / `.theme` / `.manifest` / … on the root; `.prompt` on a page). A named export
|
|
458
|
+
beside it fails the build.
|
|
459
|
+
- **Name every `[x]` segment with `.param("x", Type)`** and read query keys only via `.search("k", Type)`; values
|
|
460
|
+
arrive typed. `lang` is never declared — every stage receives it.
|
|
461
|
+
- `.head()` takes JSX or a function returning it — never a metadata object; deeper heads replace by tag identity,
|
|
462
|
+
and a canonical link / `og:url` comes only from the page's own `.head()`.
|
|
463
|
+
- In a CSR bundle a root layout's own markup is hidden: what a person must see from it renders through
|
|
464
|
+
`createPortal(…, document.body)`.
|
|
465
|
+
- `_overrides.tsx` is one `export default override({ Slot: AppComponent })` (`akanjs/ui`), no logic, no
|
|
466
|
+
`"use client"`. The legacy `export default async function Page` shape is never written anew.
|
|
467
|
+
- `libs/<lib>/page` reaches an app through `syncPageLibs`, linked into the generated `page/(libs)/(<lib>)`; edit the
|
|
468
|
+
lib source, never the link. `.config({ devOnly: true })` (a literal) keeps a route out of `akan build`.
|
|
1039
469
|
|
|
1040
470
|
```tsx
|
|
1041
471
|
export default page()
|
|
@@ -1049,149 +479,85 @@ export default page()
|
|
|
1049
479
|
});
|
|
1050
480
|
```
|
|
1051
481
|
|
|
1052
|
-
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
- `async` on the render callback only when the body awaits; the callback is not a React component, so it takes no
|
|
1059
|
-
name and no return type.
|
|
1060
|
-
- Publish a screen to agents with `.prompt(name, description)` — see MCP Exposure.
|
|
1061
|
-
- No `useState`, no `useEffect`, and no comments in page files.
|
|
482
|
+
- `.render()` gets the declared args flat and typed (no `PageProps`, no `loader=` prop). Order: `usePage()`, auth,
|
|
483
|
+
fetch, return. Hand `fetch.init*` / `view*` / `edit*` fields to sections unawaited; await only what the shell reads
|
|
484
|
+
(title, redirect, link id) in one `Promise.all`.
|
|
485
|
+
- Gate auth at `_layout.tsx` (repeating `getSelf` in the page is fine). `async` only when the body awaits; the
|
|
486
|
+
callback is not a React component, so it takes no name and no return type. No `useState`, `useEffect`, or
|
|
487
|
+
comments in page files.
|
|
1062
488
|
|
|
1063
489
|
## Akan Sync Conventions (`apps/**`, `libs/**`)
|
|
1064
490
|
|
|
1065
|
-
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
-
|
|
1076
|
-
|
|
1077
|
-
- `
|
|
1078
|
-
-
|
|
1079
|
-
|
|
1080
|
-
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
- `ui
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
`
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
`akan.keepDependencies`; `externalLibs` and `trustedDependencies` are always kept. devDependencies are realigned,
|
|
1098
|
-
never pruned. `akan install-library` lets the root win: it adds only what the root lacks and warns when the lib
|
|
1099
|
-
wants a newer version.
|
|
1100
|
-
- `libs/<libName>` may hold a `page` folder of route modules; scanSync links it into every app that opts in with `syncPageLibs`, so `apps/*/page/**/(libs)` is generated and gitignored like `public/libs`.
|
|
491
|
+
- App root files: `AGENTS.md`, `CLAUDE.md`, `akan.app.json`, `akan.config.ts`, `client.ts`, `main.ts`,
|
|
492
|
+
`package.json`, `server.ts`, `tsconfig.json`, `tsconfig.tsbuildinfo`; folders: `.akan`, `common`, `env`, `lib`,
|
|
493
|
+
`native`, `page`, `plugin`, `private`, `public`, `script`, `secrets`, `srvkit`, `ui`, `webkit`. A Capacitor-era
|
|
494
|
+
`ios` / `android` / `mobile` folder or `capacitor.config.*` in an app root is refused as a leftover (native
|
|
495
|
+
projects live under `dist/native/<app>/<target>`).
|
|
496
|
+
- Lib root files: `AGENTS.md`, `CLAUDE.md`, `README.md`, `akan.config.ts`, `akan.lib.json`, `client.ts`, `index.ts`,
|
|
497
|
+
`package.json`, `server.ts`, `tsconfig.json`, `tsconfig.spec.json`, `tsconfig.tsbuildinfo`; folders: the app's
|
|
498
|
+
minus `.akan`, `script`, `secrets` (no `main.ts` either — a lib is never booted).
|
|
499
|
+
- The allowlists' one source is `pkgs/@akanjs/devkit/workspaceLayout.ts`, read by `akan sync` (error), `akan doctor`
|
|
500
|
+
and `akan quality scan` — add an entry there and mirror it here.
|
|
501
|
+
- `apps/<app>/AGENTS.md` / `libs/<lib>/AGENTS.md` carry a generated `## Recipes In Scope` block between the
|
|
502
|
+
`akan:agent` markers — never hand-edit it (outside the markers is yours); `akan lint` fails when it is stale.
|
|
503
|
+
- `plugin/<name>.plugin.ts` declares an Akan plugin. No `apps/*/base` or `libs/*/base` — use `common/`.
|
|
504
|
+
- `lib/` root holds only `cnst.ts`, `db.ts`, `dict.ts`, `option.ts`, `sig.ts`, `srv.ts`, `st.ts`, `useClient.ts`,
|
|
505
|
+
`useServer.ts` — no hand-written file or test.
|
|
506
|
+
- Modules are `lib/<model>`, `lib/_<service>`, `lib/__scalar/<scalar>`; their UI files are
|
|
507
|
+
`<Model>.Template|Unit|Util|View|Zone.tsx`, `<Service>.Util|Zone.tsx`, `<Scalar>.Template|Unit.tsx`. A test is
|
|
508
|
+
named after the one file it covers, so `<model>.<topic>.signal.test.ts` is refused like `<model>.<topic>.signal.ts`.
|
|
509
|
+
- `ui/`, `webkit/`, `srvkit/`, `common/`, `plugin/` and module `index.ts` barrels are generated
|
|
510
|
+
(`export * from "./name";` per 1-depth entry, `export {};` when empty, so a stale one heals on sync;
|
|
511
|
+
`akan create-library` starts `common/`, `srvkit/`, `webkit/`, `ui/` with that empty barrel) — never hand-edit or
|
|
512
|
+
track them. **Never hand-write an
|
|
513
|
+
`index.tsx` / `.jsx` / `.mts` at those roots**: Bun resolves it before the generated `index.ts`, so new exports are
|
|
514
|
+
`undefined` at runtime (`akan sync` refuses it, `akan doctor` reports `facet-index-shadowed`); a namespace belongs
|
|
515
|
+
in `ui/<Folder>/index.tsx`.
|
|
516
|
+
- Sync writes barrels, module indexes and `akan.*.json` exactly as Biome prints them (`akan lint --no-fix` passes
|
|
517
|
+
right after a sync), so a lint diff in one is a generator bug — fix the generator.
|
|
518
|
+
- **`akan sync` owns package.json `dependencies`**: it adds imports at the root's version, realigns root-declared
|
|
519
|
+
entries, and drops unimported ones (logged); undeclared-at-root entries are kept with a warning. Keep a package no
|
|
520
|
+
TS file imports (a CSS `@plugin`, a runtime-only package) with `akan.keepDependencies`; `externalLibs` and
|
|
521
|
+
`trustedDependencies` always stay; devDependencies are realigned, never pruned. `akan install-library` lets the
|
|
522
|
+
root win — it adds only what the root lacks and warns when the lib wants a newer version.
|
|
1101
523
|
|
|
1102
524
|
## Layer Placement (`common/`, `webkit/`, `srvkit/`, `ui/`)
|
|
1103
525
|
|
|
1104
526
|
| Folder | Admission test | Naming |
|
|
1105
527
|
|---|---|---|
|
|
1106
|
-
| `common/` | pure, isomorphic, zero-dependency;
|
|
1107
|
-
| `webkit/` | touches `window` / `navigator` / the native bridge (`akanjs/client/native
|
|
528
|
+
| `common/` | pure, isomorphic, zero-dependency; imports only sibling `common/*` and `akanjs/base` (so no `Err`, and no throwing code) | camelCase file named after its single export |
|
|
529
|
+
| `webkit/` | touches `window` / `navigator` / the native bridge (`akanjs/client/native[/<plugin id>]`), or is a React hook | `use<Thing>.tsx`, `.tsx` even without JSX |
|
|
1108
530
|
| `srvkit/` | touches `node:*`, `Bun`, `process.env`, a secret, or a server SDK | camelCase file, PascalCase class |
|
|
1109
|
-
| `ui/` | renders JSX
|
|
1110
|
-
| `plugin/` | build- or CLI-time `AkanPlugin` | `<name>.plugin.ts`, registered in `akan.config.ts` |
|
|
1111
|
-
| `native/` | a native plugin the app or lib owns
|
|
531
|
+
| `ui/` | renders JSX or defines a look (`Recipe/`, a lib's `tokens.css`), not bound to one model | PascalCase component, camelCase sidecar, `Recipe/<name>.ts` |
|
|
532
|
+
| `plugin/` | a build- or CLI-time `AkanPlugin` | `<name>.plugin.ts`, registered in `akan.config.ts` |
|
|
533
|
+
| `native/` | a native plugin the app or lib owns — its page API reached only through a `webkit/` hook (`runtimeRule`) | `native/<id>/` |
|
|
1112
534
|
|
|
1113
535
|
- Hooks return a named object of async closures, never a tuple.
|
|
1114
|
-
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
server components call. Not `common/`: it cannot import `akanjs/ui`, where `recipe` / `tv` come from.
|
|
1119
|
-
- `libs/<lib>/ui/tokens.css` is the one CSS file a lib owns: plain `:root` custom properties for colors that must **not** follow the theme (a vendor brand color, a fixed surface). Every app whose pages reach that lib compiles it automatically, ahead of the app's own stylesheets, so nothing is imported by hand and no app can forget it. Reference them as `bg-[var(--kakao)]`; `@theme` extensions stay in the app stylesheet, because the color vocabulary is closed per stylesheet. Theme-following colors are the app's, not the lib's.
|
|
1120
|
-
- A layer-root `index.ts` is generated, but a `ui/<Folder>/index.tsx` that builds a namespace is hand-written source. The distinguishing test is that a generated barrel contains nothing but `export * from "./X";` lines.
|
|
1121
|
-
- `ui/<Folder>/index_.tsx` (trailing underscore) is the `"use client"` + `lazy()` boundary, with a server-safe `index.tsx` beside it. Collapsing the pair into one file breaks RSC.
|
|
536
|
+
- Recipes live in `ui/`: `webkit/` hooks are `"use client"`, and `common/` cannot import `akanjs/ui`.
|
|
537
|
+
- A layer-root `index.ts` is generated; a `ui/<Folder>/index.tsx` building a namespace is hand-written (a generated
|
|
538
|
+
barrel holds only `export *` lines). `ui/<Folder>/index_.tsx` is the `"use client"` + `lazy()` boundary beside a
|
|
539
|
+
server-safe `index.tsx` — merging the pair breaks RSC.
|
|
1122
540
|
|
|
1123
541
|
## Present In The Code — Do Not Imitate
|
|
1124
542
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
-
|
|
1129
|
-
|
|
1130
|
-
- Large blocks of commented-out code left in place.
|
|
1131
|
-
- Stale `// TODO: Implement …` comments above implemented methods.
|
|
1132
|
-
- `{cond && <X/>}` in JSX, hard-coded Korean bypassing `l()`, and `window.alert(...)` for user feedback.
|
|
1133
|
-
- Bare `/* eslint-disable */` blocks — use `// biome-ignore lint/<rule>: <why>`.
|
|
1134
|
-
- Raw palette grays such as `text-gray-400` instead of the semantic tokens (`text-foreground/70`).
|
|
1135
|
-
|
|
1136
|
-
A store calling `(this as unknown as RootStore).<action>()` or `.set({ … })` over
|
|
1137
|
-
`import type { RootStore } from "../st"` is **not** one of these — it is the sanctioned way to reach another store's
|
|
1138
|
-
actions and state (see `<model>.store.ts` above).
|
|
543
|
+
Warts in older files — never copy them; when two shapes disagree, follow the newer neighbour: `children: any`
|
|
544
|
+
(use `ReactNode`); hard-coded API keys or secrets (they belong in `option.ts` or env); large commented-out blocks;
|
|
545
|
+
stale `// TODO: Implement …` above implemented methods; `{cond && <X/>}`, hard-coded Korean bypassing `l()`,
|
|
546
|
+
`window.alert(...)`; bare `/* eslint-disable */` blocks; raw palette grays like `text-gray-400`. A store reaching
|
|
547
|
+
another through `(this as unknown as RootStore)` is **not** a wart — it is the sanctioned way.
|
|
1139
548
|
|
|
1140
549
|
## Secrets And Env Safety (`.env`, `infra/**`, `*secret*`, `*credential*`)
|
|
1141
550
|
|
|
1142
|
-
- Never print, summarize, commit, or expose real secret values, credentials, tokens, private keys, or `.env`
|
|
1143
|
-
|
|
1144
|
-
- Preserve the
|
|
1145
|
-
|
|
551
|
+
- Never print, summarize, commit, or expose real secret values, credentials, tokens, private keys, or `.env`
|
|
552
|
+
contents; document env keys by name with placeholders only.
|
|
553
|
+
- Preserve the env/secret flow through `bun run downloadEnv`, `uploadEnv`, `downloadSecret`, `uploadSecret`, and
|
|
554
|
+
keep Jenkins and deployment assumptions intact unless the task asks otherwise.
|
|
1146
555
|
- Treat generated env/secret artifacts as sensitive even when they are not named `.env`.
|
|
1147
556
|
|
|
1148
557
|
## Application Test Commands
|
|
1149
558
|
|
|
1150
|
-
- After changing application
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
selected one's log beside it. `Tab` walks every row and `1`-`9` jump to that app (the rail shows each
|
|
1156
|
-
app's digit), `↑↓` scroll (shift for a page, `G` back to following),
|
|
1157
|
-
`/` greps, `e` shows stderr only, `c` clears, `y` copies, `Y` copies the log path, `o` opens the browser,
|
|
1158
|
-
`r` restarts that app, `q` quits. Under `--share`, `s` copies the public URL — the selected app's, or every
|
|
1159
|
-
app's from the merged row — and the header carries it ahead of the local one, because the line `--share`
|
|
1160
|
-
printed before the view took the terminal is gone by its first repaint.
|
|
1161
|
-
**`--plain` prints prefixed interleaved lines instead**, and a pipe, a redirect or an unsized terminal
|
|
1162
|
-
downgrades to that on its own.
|
|
1163
|
-
- **A supervised session writes `local/apps/<app>/runtime/dev.log`, and that path is how a log gets handed over.**
|
|
1164
|
-
Ink repaints one frame in place, so the full-screen view leaves a bordered screenshot in the scrollback and
|
|
1165
|
-
takes the rest of the session with it — a drag-selection is cleared by the next repaint, and what survives
|
|
1166
|
-
carries borders and lines truncated to the pane. The file is the same output with no ANSI, nothing truncated,
|
|
1167
|
-
and every process of the app in arrival order, including the dev host's own build output, which reaches no
|
|
1168
|
-
other file. The previous session is kept beside it as `dev.prev.log`. Hand somebody — or an agent — the
|
|
1169
|
-
path rather than a paste: it costs one line instead of a transcript, and it can be re-read after a fix.
|
|
1170
|
-
In the view, `y` copies the lines the filters have already narrowed and `Y` copies the path.
|
|
1171
|
-
- **How many apps boot at once is the machine's answer, not a constant.** A cold boot is an app's RSS peak
|
|
1172
|
-
(~1.8GB per app, most of it the build worker — the base build, then the SSR registry's), so the default wave
|
|
1173
|
-
is what memory and cores allow — half the memory budget divided by that peak, and one app per four cores —
|
|
1174
|
-
which boots a laptop's apps together and still staggers them inside a small container. The session says
|
|
1175
|
-
which in one line. The next wave starts once an app serves and its boot builds have settled, or 30 seconds
|
|
1176
|
-
after it serves, while the app shows as ready — and `--open` opens it — as soon as it serves.
|
|
1177
|
-
`--concurrency <n>` overrides the wave size, and `AKAN_MEMORY_LIMIT` lowers the budget it derives from.
|
|
1178
|
-
- `--kill` frees the dev ports first — it resolves each port's listener, walks up to the top of that akan dev
|
|
1179
|
-
tree and signals it, so another checkout's server or a stale orphan on the same port is reclaimed. A holder
|
|
1180
|
-
that is not recognisably an akan process is reported and left alone.
|
|
1181
|
-
- **A dev server watches its own app directory and the libs it actually depends on**, transitively, resolved
|
|
1182
|
-
once at boot from the synced manifests — so a save under a lib the app never imports rebuilds and reloads
|
|
1183
|
-
nothing, and neither does a save in a sibling app. An import that makes a new lib a dependency needs a
|
|
1184
|
-
fresh `akan start`, because it needs a `sync` first anyway. Before the first `sync` the manifests do not
|
|
1185
|
-
exist yet and the whole `libs/` container is watched, which is the safe direction.
|
|
1186
|
-
- Test production build generation with `bun run akan build <appName>`.
|
|
1187
|
-
- To test a built artifact locally, run it from the generated app directory with the required Akan runtime environment variables.
|
|
1188
|
-
- A server a test boots in-process takes `start({ listen: false, schedules: false })` to run no internal (init,
|
|
1189
|
-
interval, cron, queue worker); `listen: false` alone still runs them, as a batch replica does. A server process a
|
|
1190
|
-
test spawns from source resolves the workspace from its cwd and gets the root `.env` from Bun, so spawn it inside
|
|
1191
|
-
the repo with `bun --no-env-file`, or spawn the built `dist/apps/<app>` output, which needs neither.
|
|
1192
|
-
|
|
1193
|
-
```bash
|
|
1194
|
-
cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=<repo> AKAN_PUBLIC_SERVE_DOMAIN="<domain>" \
|
|
1195
|
-
AKAN_PUBLIC_APP_NAME=<appName> AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation \
|
|
1196
|
-
AKAN_PUBLIC_BASE_PATHS=<basePaths> bun main.js
|
|
1197
|
-
```
|
|
559
|
+
- After changing application code, run `bun run akan start <appName>` (`a,b`, `a b`, or `all` for several); check
|
|
560
|
+
the production build with `bun run akan build <appName>`.
|
|
561
|
+
- A supervised session writes `local/apps/<app>/runtime/dev.log` (previous: `dev.prev.log`) — hand over that path,
|
|
562
|
+
not a paste of the repainting full-screen view. Keys, `--plain`, boot waves, `--kill`, watch scope, running a
|
|
563
|
+
built artifact, booting a server in a test: `runtimeRule`.
|