@akanjs/cli 3.0.0-alpha.11 → 3.0.0-alpha.110
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/{abstractCompactor-e5yq60qz.js → abstractCompactor-mjh5ppsq.js} +3 -5
- package/{agent.command-a3n15c6t.js → agent.command-d2gfxfck.js} +11 -13
- package/{application.command-rdm3et0g.js → application.command-pj2m7584.js} +65 -38
- package/{applicationBuildRunner-6m8n71w4.js → applicationBuildRunner-kxwbssgn.js} +27 -23
- package/{applicationReleasePackager-gg8ccbeh.js → applicationReleasePackager-4yasxwv9.js} +8 -9
- package/buildBatch.proc.js +12 -14
- package/capacitorApp-36rbr858.js +12 -0
- package/{cloud.command-56aeekqn.js → cloud.command-gxqp8xdv.js} +15 -18
- package/commandManifest.json +1 -1
- package/{context.command-ysxawe7h.js → context.command-ez8a0zyz.js} +19 -23
- package/dependencyScanner-shs82ynp.js +8 -0
- package/devTui-40ggmd99.js +553 -0
- package/devTuiView-c08ccbat.js +9 -0
- package/getDirname-dr3jz1xg.js +7 -0
- package/{guideline.command-yqf9a372.js → guideline.command-xmqwx8zm.js} +24 -22
- package/guidelines/agentRule/agentRule.generate.json +31 -0
- package/guidelines/agentRule/agentRule.instruction.md +531 -0
- package/guidelines/conventions/conventions.instruction.md +493 -369
- package/guidelines/framework/framework.generate.json +3 -2
- package/guidelines/framework/framework.instruction.md +0 -9
- package/guidelines/mcpRule/mcpRule.generate.json +35 -0
- package/guidelines/mcpRule/mcpRule.instruction.md +460 -0
- package/guidelines/modelStore/modelStore.instruction.md +12 -0
- package/guidelines/modelTemplate/modelTemplate.instruction.md +5 -0
- package/guidelines/queryRule/queryRule.generate.json +31 -0
- package/guidelines/queryRule/queryRule.instruction.md +178 -0
- package/guidelines/runtimeRule/runtimeRule.generate.json +25 -0
- package/guidelines/runtimeRule/runtimeRule.instruction.md +220 -0
- package/guidelines/ssrRule/ssrRule.instruction.md +51 -5
- package/guidelines/transportRule/transportRule.generate.json +31 -0
- package/guidelines/transportRule/transportRule.instruction.md +60 -0
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +54 -764
- package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +33 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +467 -0
- package/incrementalBuilder.proc.js +26 -25
- package/{index-8pd02prk.js → index-259dsnz3.js} +58 -25
- package/{index-yzsxc30b.js → index-2tv3fdx4.js} +975 -234
- package/index-3d9y066c.js +76 -0
- package/{index-76rn3g2c.js → index-47pj3aee.js} +4 -4
- package/{index-m90fn8xe.js → index-4edzf1gr.js} +32 -32
- package/{index-3789fdpg.js → index-600zhnve.js} +94 -61
- package/{index-1577bej2.js → index-6wwzs1bc.js} +2 -2
- package/{index-cv9cxs5k.js → index-7737gxcp.js} +18 -18
- package/{index-a5cs7d8r.js → index-8few9nyh.js} +13 -13
- package/{index-mewacxye.js → index-8pm30m73.js} +57 -57
- package/{index-wnp7hwq7.js → index-92zztqy1.js} +7 -7
- package/{index-fgc8r6dj.js → index-a5vmm6x4.js} +3 -1
- package/{index-cav1azma.js → index-a64esmtb.js} +1106 -1369
- package/{index-nwqqssnb.js → index-aj7tyh5w.js} +26 -23
- package/{index-xys926f2.js → index-by5gpp8x.js} +4 -4
- package/{index-yzmqvg23.js → index-fz5q3gd7.js} +8 -8
- package/{index-kcsa1zff.js → index-gyd220p2.js} +4 -4
- package/{index-j37qq1f2.js → index-h7yw2z9n.js} +7 -7
- package/{index-bjpxzr6s.js → index-hwkygn9s.js} +51 -3
- package/index-j9yhg404.js +159 -0
- package/{index-46tjzh6s.js → index-kcxmv48c.js} +29 -29
- package/{index-0cj2zxbm.js → index-kn1h7p1x.js} +12 -11
- package/{index-e4b2ae6b.js → index-ktb5njgy.js} +41 -34
- package/{index-etwh7sd3.js → index-mak3cd28.js} +100 -32
- package/{index-xjd3rqzj.js → index-n68jx79w.js} +6 -15
- package/{index-z1s5f4e0.js → index-ne2qy0nk.js} +36 -37
- package/{index-65aeh789.js → index-pnqbkf5p.js} +1 -1
- package/{index-67546d0j.js → index-q0y2jq9j.js} +14 -2
- package/{index-4mgkgba3.js → index-rwg2aa4f.js} +14 -14
- package/{index-bzznxgcx.js → index-v0qh106m.js} +1002 -128
- package/{index-j9cxndc0.js → index-w8kmx1b6.js} +20 -11
- package/{index-h5whaj26.js → index-ws5e0j51.js} +16 -12
- package/{index-9va8zayb.js → index-wt7qrtd4.js} +27 -27
- package/index.js +22 -25
- package/{library.command-x6t29a30.js → library.command-nbsky672.js} +11 -10
- package/{localRegistry.command-649g1n7b.js → localRegistry.command-ts6t5e5a.js} +20 -23
- package/{module.command-8w8ys5t3.js → module.command-29vqzrh8.js} +13 -16
- package/{package.command-zgy7f3fz.js → package.command-axv0abmn.js} +8 -10
- package/package.json +5 -7
- package/{page.command-yjmnkyk2.js → page.command-vs9s3zrh.js} +7 -9
- package/{primitive.command-gfqaagsg.js → primitive.command-cbxmqbtc.js} +13 -16
- package/{quality.command-ec8r1dn3.js → quality.command-gmbs7nws.js} +184 -334
- package/recipeScanner-fpagtgc1.js +11 -0
- package/{repair.command-p9ey6mkm.js → repair.command-bqsjptsj.js} +12 -14
- package/{routeSourceValidator-e4jmbfcx.js → routeSourceValidator-apyqw7wk.js} +90 -19
- package/{scalar.command-9dhmqhtm.js → scalar.command-6k1vhe9s.js} +11 -14
- package/subspace.command-35a3t6vv.js +755 -0
- package/templates/app/env/env.client.debug.ts.template +1 -5
- package/templates/app/env/env.client.develop.ts.template +1 -5
- package/templates/app/env/env.client.local.ts.template +1 -5
- package/templates/app/env/env.client.main.ts.template +1 -5
- package/templates/app/env/env.client.testing.ts.template +1 -5
- package/templates/app/env/env.client.type.ts +2 -6
- package/templates/app/env/env.server.debug.ts.template +1 -5
- package/templates/app/env/env.server.develop.ts.template +1 -5
- package/templates/app/env/env.server.local.ts.template +1 -5
- package/templates/app/env/env.server.main.ts.template +1 -5
- package/templates/app/main.ts +3 -1
- package/templates/app/page/_index.tsx +3 -3
- package/templates/app/page/_layout.tsx +31 -32
- package/templates/appSample/lib/task/task.signal.ts +11 -6
- package/templates/appSample/page/task/[taskId]/_index.tsx +22 -27
- package/templates/appSample/page/task/[taskId]/edit.tsx +25 -30
- package/templates/appSample/page/task/_index.tsx +3 -7
- package/templates/appSample/page/task/_layout.tsx +4 -15
- package/templates/appSample/page/task/new.tsx +4 -10
- package/templates/appSample/srvkit/AuthGuard.ts +2 -2
- package/templates/crudPages/[__model__Id]/edit/page.tsx +27 -29
- package/templates/crudPages/[__model__Id]/page.tsx +24 -32
- package/templates/crudPages/new/page.tsx +26 -25
- package/templates/crudPages/page.tsx +27 -26
- package/templates/crudSinglePage/page.tsx +26 -21
- package/templates/env/_env.server.type.ts +7 -5
- package/templates/lib/db.ts +1 -1
- package/templates/lib/dict.ts +1 -1
- package/templates/lib/sig.ts +5 -1
- package/templates/lib/srv.ts +6 -2
- package/templates/module/__model__.dictionary.ts +4 -1
- package/templates/module/__model__.signal.ts +2 -3
- package/templates/module/__model__.store.ts +1 -2
- package/templates/workspaceRoot/.gitignore.template +0 -1
- package/templates/workspaceRoot/.husky/pre-commit.template +16 -0
- package/templates/workspaceRoot/.vscode/settings.json.template +1 -1
- package/templates/workspaceRoot/AGENTS.md.template +4 -0
- package/templates/workspaceRoot/CLAUDE.md.template +19 -0
- package/templates/workspaceRoot/biome.json.template +1 -1
- package/templates/workspaceRoot/docs/AI-DEVELOPMENT.md.template +10 -14
- package/templates/workspaceRoot/docs/GENERATED.md.template +12 -10
- package/templates/workspaceRoot/package.json.template +2 -4
- package/typeChecker-3xgrzc2v.js +7 -0
- package/typecheck.proc.js +4 -5
- package/{workflow.command-ycwc6zr6.js → workflow.command-nwpcdc8p.js} +22 -25
- package/{workspace.command-xhyhajxa.js → workspace.command-aczjxzbf.js} +64 -66
- package/capacitorApp-mr81qdxq.js +0 -61
- package/dependencyScanner-f6grrep3.js +0 -9
- package/getDirname-m9s0cmhq.js +0 -8
- package/index-mxvakhsm.js +0 -66
- package/index-r24hmh0q.js +0 -4
- package/recipeScanner-tghkzrrt.js +0 -12
- package/typeChecker-kravn7ns.js +0 -8
- /package/{index-0wae5ebk.js → index-meh558jy.js} +0 -0
- /package/{index-ss469dec.js → index-nz8prx9g.js} +0 -0
|
@@ -19,44 +19,80 @@
|
|
|
19
19
|
- Prefer established nearby patterns over introducing a new abstraction.
|
|
20
20
|
- Add new abstractions only when they remove real duplication or match an existing project pattern.
|
|
21
21
|
|
|
22
|
+
## Deep-Dive Guidelines
|
|
23
|
+
|
|
24
|
+
This file carries the rules. The reasoning, the measurements, and the per-feature edge cases live in guidelines
|
|
25
|
+
you fetch on demand — `get_guideline` with the name, or `akan guideline show <name>`. Fetch the relevant one
|
|
26
|
+
**before** a deep pass on that area; a section below that points at one is a summary, not the whole contract.
|
|
27
|
+
|
|
28
|
+
| name | covers |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `ssrRule` | server-share targets, the `akan.ssr.*` warnings, the client-boundary playbook |
|
|
31
|
+
| `runtimeRule` | `web` / `csr` surfaces, gateway vs solo processes, logging, the generated image, shipped assets |
|
|
32
|
+
| `queryRule` | slices and hydration, the generated filter methods, full-text search, cascade removal |
|
|
33
|
+
| `transportRule` | guards across HTTP and websocket, socket identity and cleanup, binary pubsub, mutation verbs |
|
|
34
|
+
| `mcpRule` | MCP configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions |
|
|
35
|
+
| `agentRule` | the in-page agent's chat options, zones, slash commands, transcript compaction |
|
|
36
|
+
| `cssRule` | the full semantic token set for a theme pass |
|
|
37
|
+
| `componentRule` | the `_overrides.tsx` slot list and drop-in component patterns |
|
|
38
|
+
| `recipeRule` | authoring and consuming Tailwind-variant recipes |
|
|
39
|
+
| `workspaceRecipes` | worked step-by-step recipes and the auto-generated API reference |
|
|
40
|
+
|
|
22
41
|
## Lint-Enforced Rules (These Break The Build)
|
|
23
42
|
|
|
24
|
-
Enforced by `biome.json
|
|
25
|
-
that looks wrong; do not "fix" it
|
|
43
|
+
Enforced by `biome.json`, which extends `@akanjs/devkit/biome.base.json` — that file scopes every grit plugin in
|
|
44
|
+
`@akanjs/devkit/lint/` to the paths it applies to. Several rules produce output that looks wrong; do not "fix" it
|
|
45
|
+
back.
|
|
26
46
|
|
|
27
47
|
- **Never hand-order Tailwind classes.** `nursery/useSortedClasses` is an error and also sorts the string
|
|
28
|
-
arguments to `cn()`. Sorter output such as `font-bold text-2xl text-
|
|
29
|
-
|
|
30
|
-
- **Stay inside the color vocabulary.** Vocabulary closure strips the raw Tailwind palette, so these render as
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
48
|
+
arguments to `cn()`. Sorter output such as `font-bold text-2xl text-foreground` or `border-foreground/5 border-t`
|
|
49
|
+
is correct. Write the classes in any order, run the formatter, leave the result.
|
|
50
|
+
- **Stay inside the color vocabulary.** Vocabulary closure strips the raw Tailwind palette, so these render as no
|
|
51
|
+
CSS and fail lint: raw palette classes (`bg-blue-500`), arbitrary color values (`bg-[#3b82f6]`), an arbitrary
|
|
52
|
+
class built by interpolation, daisyUI legacy classes (`btn-primary`, `card-body`) and its dropped color slots
|
|
53
|
+
(`bg-base-100`, `text-base-content`, `text-primary-content`, `bg-error` — use `background`/`muted`/`border`,
|
|
54
|
+
`foreground`, `<color>-foreground`, `destructive`), and color literals in `style={{...}}`. Use semantic tokens
|
|
34
55
|
(`bg-primary`, `text-foreground/70`). A legitimate fixed color (OS-chrome mockups, data-viz) takes a
|
|
35
|
-
`// biome-ignore lint/plugin: <reason
|
|
56
|
+
`// biome-ignore lint/plugin: <reason>`. (`no-raw-palette-class`, `no-arbitrary-color`, `no-daisyui-legacy-class`,
|
|
57
|
+
`no-inline-color`, `no-interpolated-arbitrary-class`)
|
|
36
58
|
- **Never `throw new Error`.** Throw `new Err("<module>.error.<key>")` and register the key as `[en, ko]` in that
|
|
37
59
|
module's dictionary `.error({})`. Import `Err` from `"../dict"` on the server and from `"@libs/<lib>/client"` or
|
|
38
|
-
`"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts
|
|
39
|
-
|
|
40
|
-
out of them.
|
|
60
|
+
`"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts tests, `*.constant.ts`, `common/**`, and `env/**`
|
|
61
|
+
— the last two have no legal `Err` import path, so keep throwing code out of them.
|
|
41
62
|
- **Never import a third-party package** from `page/**`, from any barrel, or from any
|
|
42
63
|
`*.{constant,dictionary,document,service,signal,store}.ts` / `*.{Template,Unit,Util,View,Zone}.tsx`
|
|
43
|
-
(`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims
|
|
44
|
-
`
|
|
45
|
-
load-bearing, not cruft. Do not delete them.
|
|
64
|
+
(`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims in a lib's
|
|
65
|
+
`common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft.
|
|
46
66
|
- **`#private` is banned in exactly four file suffixes:** `*.constant.ts`, `*.document.ts`, `*.service.ts`, and
|
|
47
67
|
`*.store.ts` (`no-js-private-class-method.grit`). The rule is scoped by file path, not by class shape, so
|
|
48
68
|
`#private` remains the house style everywhere under `srvkit/`, including `adapt()` adapter classes.
|
|
49
69
|
- **No `console.log` / `console.debug`.** Only `assert`, `error`, `info`, and `warn` are allowed. Server code uses
|
|
50
70
|
the injected `this.logger.*` or `new Logger("ClassName")`.
|
|
71
|
+
- **Never call `logger.log()` / `Logger.log()`** (`no-deprecated-log-level.grit`). The ladder is `trace verbose
|
|
72
|
+
debug info warn error`; `log` was a seventh tier *below* `info` that `AKAN_PUBLIC_LOG_LEVEL=info` silently
|
|
73
|
+
dropped. The method is kept and emits at `info`, so a call reads like a distinct level and is not one — write
|
|
74
|
+
`.info()`.
|
|
51
75
|
- **Never write a `//!` marker in browser-reachable code** — `ui/`, `webkit/`, `common/`, `page/**/*.tsx`,
|
|
52
76
|
`*.constant.ts`, `*.store.ts`, and the five module component suffixes (`no-bang-comment-in-client.grit`). Bun
|
|
53
77
|
classifies `//!` and `/*!` as legal comments and keeps them through minification, so the note ships to every
|
|
54
78
|
visitor. Use `// FIXME:` there; `//!` stays legal in server, `srvkit/`, and CLI files.
|
|
55
79
|
- **Never return a value from a store action** (`no-return-in-store-action.grit`). Every method of a `store(...)`
|
|
56
|
-
class dispatches through `st.do.<action>()`,
|
|
57
|
-
|
|
58
|
-
|
|
80
|
+
class dispatches through `st.do.<action>()`, typed `void` / `Promise<void>`, so the value is unreachable — write
|
|
81
|
+
it into state with `this.set({ ... })`. A bare `return;` guard, a `return` inside a nested callback, a getter,
|
|
82
|
+
and a `static` helper are all still fine.
|
|
59
83
|
- **Never redeclare a generated CRUD endpoint name** in `*.signal.ts` (`no-redeclare-predefined-endpoint.grit`).
|
|
84
|
+
- **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** (`no-model-type-in-util-zone.grit`). Those two
|
|
85
|
+
roles are always client components, so a `cnst.Banner` / `cnst.LightBanner` prop is a class instance the server
|
|
86
|
+
has to hand across the boundary; take `bannerId: string` and read the model from the store instead. Only prop
|
|
87
|
+
positions are read, and only shapes that are actually an instance. Exempt: an indexed enum access
|
|
88
|
+
(`cnst.<Enum>["value"]`), a `ClientInit` / `ClientView` / `ClientEdit` or `ModelsProps<…>` type argument, a
|
|
89
|
+
function-typed prop, and any `cnst` type that never leaves the file. Other indexed accesses
|
|
90
|
+
(`ModelProps<"setting", cnst.LightSetting>`, `cnst.Banner["image"]`) stay flagged.
|
|
91
|
+
- **Never wrap a form setter in a pass-through arrow** (`no-unpublished-form-setter.grit`).
|
|
92
|
+
`onChange={(type) => st.do.setTypeOnTicket(type)}` runs identically to `onChange={st.do.setTypeOnTicket}`, but
|
|
93
|
+
the arrow is a fresh anonymous closure, so the control emits no `data-akan-action` and publishes no agent tool
|
|
94
|
+
for the field — a silent failure in two lines that read the same. A wrapper that transforms the value, adds a
|
|
95
|
+
statement, or writes a nested path with `writeOnX` stays legal; publish that one with an explicit `st.tool`.
|
|
60
96
|
- **No deep imports past a barrel** (`no-deep-internal-import.grit`). Cross-module constant references such as
|
|
61
97
|
`../map/map.constant` are the sanctioned exception.
|
|
62
98
|
- **Never import across the client/server boundary.** Client files (`ui/`, `webkit/`, `page/`, `*.store.ts`, every
|
|
@@ -66,23 +102,35 @@ that looks wrong; do not "fix" it back.
|
|
|
66
102
|
`*.store.ts`, a module component, `ui/`, `webkit/`, a package `client` entrypoint, or the `st` / `store` /
|
|
67
103
|
`useClient` barrels (`no-import-client-in-server.grit`). Shared files — `common/` and `*.constant.ts` — are held
|
|
68
104
|
to **both**, so they reach neither side. `import type` is erased before bundling and stays legal in every
|
|
69
|
-
direction; a mixed value-and-type import is not exempt. Scoped to `apps/**` and `libs
|
|
70
|
-
implements the boundary and is where the two graphs legitimately meet.
|
|
105
|
+
direction; a mixed value-and-type import is not exempt. Scoped to `apps/**` and `libs/**`.
|
|
71
106
|
- **Server-component discipline** is enforced on `page/**`, `*.Unit.tsx`, and `*.View.tsx`
|
|
72
107
|
(`no-import-client-functions.grit`, `no-use-client-in-server.grit`, `non-scalar-props-restricted.grit`).
|
|
108
|
+
- **Never write an async component outside `page/`** (`no-async-component-in-ui.grit`, scoped to
|
|
109
|
+
`{apps,libs}/**/ui/**/*.tsx`). React has no async client component, so a `ui/` component that awaits breaks as
|
|
110
|
+
soon as a client parent renders it, and the load drops below the route, which could have started it before the
|
|
111
|
+
first byte. Await in the page — or hand an unawaited `fetch.*` field to a `Zone` as an `init` / `view` prop, or
|
|
112
|
+
to a `<Load.Stream of={…}>` that awaits it behind a boundary of its own — and take the resolved data as a prop.
|
|
113
|
+
Only a PascalCase binding whose own initializer is `async` is matched, so an async handler inside a synchronous
|
|
114
|
+
component and `lazy(async () => import(…))` are untouched.
|
|
115
|
+
- **Never call `fetch.init*` from a client file** (`no-init-fetch-in-client.grit`). `fetch.init<Model><Suffix>` and
|
|
116
|
+
`fetch.get<Model>Init<Suffix>` compose the slice's list and insight queries into the hydration snapshot that
|
|
117
|
+
`Load.Units` / `Load.View` seed the store from. From a route it resolves before the first byte; after hydration
|
|
118
|
+
it is two extra round-trips for a shell the browser already painted. Load it in the page and pass the result
|
|
119
|
+
down as an `init` prop — or hand the unawaited `x<Model>Init<Suffix>` promise across — and reload from the client
|
|
120
|
+
through the generated `st.do.init<Model><Suffix>()`. Gated on the real `"use client"` directive plus
|
|
121
|
+
`*.store.ts`, and matched by shape (`init` + `Capitalize<refName>` + `Capitalize<suffix>`), so a hand-written
|
|
122
|
+
`initPayment` is out.
|
|
73
123
|
- `noArrayIndexKey` and `useExhaustiveDependencies` are **off** on purpose: `key={idx}` for embedded scalars and
|
|
74
124
|
short dependency arrays are intentional, not oversights.
|
|
75
125
|
- **A grit plugin diagnostic is suppressed as `lint/plugin`, not `plugin`** — `// biome-ignore lint/plugin: <reason>`
|
|
76
|
-
for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form
|
|
77
|
-
own category name suggests does nothing.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
- **`akan lint` prints up to 200 diagnostics** (`--maxDiagnostics <n>`, `0` for no limit). Biome's own default is 20
|
|
85
|
-
with no count, which reads as progress when the mix of findings merely changed.
|
|
126
|
+
for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form
|
|
127
|
+
Biome's own category name suggests does nothing. The module-convention plugins apply to `apps/**` and `libs/**`
|
|
128
|
+
only, so a plain package under `pkgs/` never needs the escape hatch.
|
|
129
|
+
- **`biome.json` is strict JSON — a comment in it breaks config resolution.** Biome does not report the parse
|
|
130
|
+
error; it falls back to discovery and aborts on whatever nested config the walk finds. Rename the file to
|
|
131
|
+
`biome.jsonc` to document a disabled rule.
|
|
132
|
+
- **`akan lint` prints up to 200 diagnostics** (`--max-diagnostics <n>`, `0` for no limit). Biome's own default is
|
|
133
|
+
20 with no count, which reads as progress when the mix of findings merely changed.
|
|
86
134
|
|
|
87
135
|
## Coding Style (`**/*.{ts,tsx}`)
|
|
88
136
|
|
|
@@ -121,7 +169,7 @@ that looks wrong; do not "fix" it back.
|
|
|
121
169
|
- Prefer focused behavior tests for public contracts and edge cases over implementation-detail assertions.
|
|
122
170
|
- Run package suites with `bun run akan test <pkg>` from the repo root, or `cd <pkg> && bun test --isolate`. Plain `bun test` without `--isolate` shares one global object across test files and fails dozens of tests from cross-file state pollution (`bunfig.toml` `[test] isolate` is not honored as of Bun 1.3), and running `bun test` from the repo root breaks subprocess stdio pipes.
|
|
123
171
|
- Split signal tests in two. `<model>.signal.spec.ts` holds reusable fixtures built on `sampleOf(cnst.XInput)` with explicit `Promise<cnst.X>` return types and **no assertions**. `<model>.signal.test.ts` holds the assertions: `describe("<Model> Signal")`, `let` fixtures at describe scope, one `beforeAll`, story-ordered `it`s, and negatives via `await expect(p).rejects.toThrow()`.
|
|
124
|
-
- `lib/user/user.signal.spec.ts` is the one place agent types are re-exported and re-typed; import `UserAgent` / `AdminAgent` from there rather than from the
|
|
172
|
+
- `lib/user/user.signal.spec.ts` is the one place agent types are re-exported and re-typed; import `UserAgent` / `AdminAgent` from there rather than from the owning lib directly.
|
|
125
173
|
- A placeholder `it` with a descriptive title is an acceptable floor. Write a real suite when the behaviour is security-relevant.
|
|
126
174
|
|
|
127
175
|
## Comments
|
|
@@ -152,7 +200,7 @@ Do not narrate code. Do document the thing the code cannot say. Both halves are
|
|
|
152
200
|
- Use Bun and ESM assumptions from the root `tsconfig.json`.
|
|
153
201
|
- Prefer path aliases over deep relative imports when crossing package boundaries.
|
|
154
202
|
- Use `akanjs/*` for framework facets, `@apps/*` for apps, `@libs/*` for shared libs, and `@contract/*` for contract code.
|
|
155
|
-
- Respect existing client/server entrypoints
|
|
203
|
+
- Respect existing client/server entrypoints — `@libs/<lib>/client` / `@libs/<lib>/server` and `@apps/<app>/client` / `@apps/<app>/server`.
|
|
156
204
|
- Let Biome organize imports instead of manually reshuffling unrelated imports.
|
|
157
205
|
- Namespace the generated barrels in backend `.ts` files: `import * as cnst from "../cnst"`, `* as db`, `* as srv`. Use `import type * as srv` in services so the runtime graph stays lazy, and a value import in signals.
|
|
158
206
|
- 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.
|
|
@@ -207,62 +255,86 @@ are, because those are the ones the server could have done.
|
|
|
207
255
|
|
|
208
256
|
### Server-Side Implementation Playbook
|
|
209
257
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
`
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
`
|
|
258
|
+
1. **Wrap the interaction, not the UI.** The smallest useful client component adds one behaviour and renders
|
|
259
|
+
`children` untouched, so the markup inside never reaches the bundle.
|
|
260
|
+
2. **Split compound components so panels stay on the server.** `Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel` in
|
|
261
|
+
`akanjs/ui` is the shape: only the provider and menu hold state. Never one `"use client"` file with a mode
|
|
262
|
+
`useState` and every panel body inlined.
|
|
263
|
+
3. **Sync state instead of fetching it.** The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and passes the
|
|
264
|
+
result into a `Zone` as an `init` / `view` prop; `Load.Units` / `Load.View` hydrate the store from it. Never a
|
|
265
|
+
`useEffect(…, [])` that fetches on mount.
|
|
266
|
+
4. **Push the boundary down to the leaf that needs it.** A store-reading `Zone` should hold zero markup and
|
|
267
|
+
delegate to a server `View`.
|
|
268
|
+
5. **Hand each promise across, not the awaited value.** `fetch.init<Model><Suffix>` / `view<Model>` / `edit<Model>`
|
|
269
|
+
are awaitable *and* destructurable: `const { xInitInY, xListInY } = fetch.initXInY(id)` hands out one promise
|
|
270
|
+
per field with both queries already in flight. Give `xInitInY` to a `Zone` and `xListInY` to a
|
|
271
|
+
`<Load.Stream of={…}>`; each renders behind its own boundary as its own data lands, so the page never waits
|
|
272
|
+
for the slowest. `await` still returns the old shape and keeps the whole section in the shell — which is what
|
|
273
|
+
SEO snapshots, prerendering and pre-hydration E2E read, so await what the page needs immediately and stream
|
|
274
|
+
the rest. `xListInY` / `xInsightInY` hold hydrated model instances that React Flight refuses as client props:
|
|
275
|
+
consume them in a server component, never as a `Zone` prop.
|
|
276
|
+
6. **Use named `ReactNode` slots, not just `children`** — `Layout.Navbar` takes `title`, `back`, `left`, `right`,
|
|
277
|
+
and `children`, so a client shell composes server content in five places instead of absorbing it.
|
|
278
|
+
7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
|
|
279
|
+
lookups belong in a module-scope `as const` map.
|
|
280
|
+
8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any HTML is sent.
|
|
281
|
+
9. **Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants, or
|
|
282
|
+
`<details>`/`<summary>`, keeps both branches server-rendered.
|
|
283
|
+
10. **Keep the heavy island out of the first load** — behind the `ui/<Folder>/index_.tsx` + `lazy()` pair.
|
|
284
|
+
`usePage()` and `l()` work in server components, so translation never forces a boundary.
|
|
285
|
+
|
|
286
|
+
Full version with code, the `Tab` composition example, and a review checklist: `get_guideline` with `ssrRule`.
|
|
287
|
+
|
|
288
|
+
## Runtime & Build Configuration
|
|
289
|
+
|
|
290
|
+
What an app serves, how many processes it runs, where its logs go, and what ships in its image. All of it is
|
|
291
|
+
declared in `akan.config.ts` and narrowed — never widened — by an env at boot. Domain code reaches none of it.
|
|
292
|
+
Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runtimeRule`.
|
|
293
|
+
|
|
294
|
+
- **Web surfaces are `web: true | false | { csr: boolean }`.** `{ csr: false }` drops the mobile/CSR bundle,
|
|
295
|
+
`false` is an API-only build that serves nothing under `page/`. There is no CSR-without-SSR option, by type —
|
|
296
|
+
the CSR bundle inlines the stylesheet the SSR base artifact compiles. `AKAN_SSR` / `AKAN_CSR` narrow further at
|
|
297
|
+
runtime and can never switch a surface the build left out back on. `akan start` ignores `web` and keeps the
|
|
298
|
+
whole dev surface.
|
|
299
|
+
- **One traffic replica runs in the container's only process.** `AKAN_REPLICA=0,0,1` — the default everywhere —
|
|
300
|
+
has nothing to balance, so there is no gateway and no unix-socket proxy hop. Two or more replicas, a batch-only
|
|
301
|
+
replica, `AKAN_SOLO=false`, passing `replica` to `new AkanApp(...)`, or `akan start` all bring the gateway back.
|
|
302
|
+
The RSC worker is always its own process (`--conditions react-server`). A solo process answers
|
|
303
|
+
`/_akan/app/health`, `/_akan/app/metrics` and `/_akan/bench/ping` in the gateway's own shape, and owns the
|
|
304
|
+
rotating log file; nothing supervises it but the orchestrator's probes.
|
|
305
|
+
- **Route prefixes move together or not at all.** `new AkanApp({ prefix, websocketPrefix })` in `main.ts` moves
|
|
306
|
+
the server's routes, the gateway's websocket upgrade and — through the SSR bootstrap script — the
|
|
307
|
+
`fetchClient` in every tab the server renders. A prebuilt CSR shell or a Capacitor bundle is never rendered by
|
|
308
|
+
a server, so `api: { prefix, websocketPrefix }` in `akan.config.ts` is what those follow; declare it there too
|
|
309
|
+
when the app ships either. Read the value with `getApiPrefix()` / `getWsPrefix()` from `akanjs/base` — never
|
|
310
|
+
write `/api` or `/ws` as a literal, and never try to hand it down as a React prop: `FetchClient` fixes its
|
|
311
|
+
origin before the first component renders. A blank prefix, a bare `/`, and one that shadows a basePath are all
|
|
312
|
+
refused.
|
|
313
|
+
- **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel** — the barrel re-exports
|
|
314
|
+
`AkanServer`, whose graph the gateway never runs. Keep entrypoint imports at the leaf.
|
|
315
|
+
- **Every `Logger` call builds a `LogRecord`** carrying `traceId`, `endpoint` and `origin` alongside the message,
|
|
316
|
+
so one request's lines share a trace. Request context is on by default, production included
|
|
317
|
+
(`AKAN_LOG_CONTEXT=0` opts out). **Give every sink a floor** — `Logger.addSink(sink, { minLevel })`; a sink with
|
|
318
|
+
none makes every `verbose` call render. **Never log per delivered record in anything that delivers records.**
|
|
319
|
+
Tail a running server with `akan logs <app>` (`--level --grep --endpoint --trace --child --role --origin
|
|
320
|
+
--since`, `--json`) or `.tail` / `.trace <id>` inside `akan console`.
|
|
321
|
+
- **`AKAN_LOG_FORMAT=ndjson` makes the container's stdout one JSON record per line, and the hub owner (gateway or
|
|
322
|
+
solo replica) its only writer** — every other server process turns its console off and forwards from
|
|
323
|
+
`AKAN_LOG_STDOUT_LEVEL` (default: the console level); a child's or the RSC worker's crash stack is wrapped as a
|
|
324
|
+
`raw=true` record instead of breaking the stream. Text stays the default; turn it on in the deployment.
|
|
325
|
+
`AKAN_LOG_STREAM_TOKEN` mounts `/_akan/app/logs` (SSE, `Last-Event-ID` resume, explicit `gap` events); unset,
|
|
326
|
+
the route does not exist. `AKAN_LOG_CANONICAL=1|slow` writes one `ok|error <endpoint> ms= status=` line per call;
|
|
327
|
+
`AKAN_LOG_FLIGHT=1` keeps each call's last 64 sub-level records and promotes them (`flight=true`) only when it
|
|
328
|
+
failed or ran past `AKAN_LOG_FLIGHT_MS`; `x-akan-debug` lowers one request to `trace` — in `local`, or with the
|
|
329
|
+
`AKAN_LOG_DEBUG_HEADER` secret. Structured values go in `LogRecord.attrs` via `Logger.emit(...)`; a key naming a
|
|
330
|
+
secret is `[redacted]` before the record exists.
|
|
331
|
+
- **`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
|
|
332
|
+
assembles one from; the string form takes no contributions. The generated image installs `ca-certificates` and
|
|
333
|
+
`tzdata` and nothing else, so an app needing `ffmpeg` or Chromium declares it in `preRuns` / `postRuns`. A lib
|
|
334
|
+
declares the steps its own runtime needs and every mounting app inherits them.
|
|
335
|
+
- **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
|
|
336
|
+
trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
|
|
337
|
+
the `akan.config.ts` that owns the font, written against that scope's own `public/`.
|
|
266
338
|
|
|
267
339
|
## React Components And Styling (`**/*.tsx`)
|
|
268
340
|
|
|
@@ -270,13 +342,21 @@ Full version with code, the `Tab` composition example, and a review checklist: `
|
|
|
270
342
|
- Never `React.FC`, never `defaultProps`, never `PropsWithChildren`. Defaults go in the destructuring (`prefix = ""`); children are typed `children: ReactNode`.
|
|
271
343
|
- `"use client"` on line 1 above the imports is mechanical by file role: every `.Zone.tsx`, `.Template.tsx`, and `.Util.tsx` has it; no `.Unit.tsx` or `.View.tsx` ever does. `usePage()` is legal in server files.
|
|
272
344
|
- Conditional render is `cond ? <X/> : null`. Never `{cond && <X/>}` — in a `className` context it renders the literal string `"false"`. Early `return null` is for guard clauses only.
|
|
273
|
-
- Never hand-roll loading, empty, or list states. Use `Load.Units` / `Load.View` / `Load.Edit` with `renderItem`, `renderList`, `renderView`, and `renderEmpty`; `<Empty />` for a bare placeholder; and `Model.New` / `Model.Edit` / `Model.SureToRemove` for CRUD modals.
|
|
345
|
+
- Never hand-roll loading, empty, or list states. Use `Load.Units` / `Load.View` / `Load.Edit` with `renderItem`, `renderList`, `renderView`, and `renderEmpty`; `<Empty />` for a bare placeholder; and `Model.New` / `Model.Edit` / `Model.SureToRemove` for CRUD modals. For data no `Load.*` covers, `<Load.Stream of={promise} fallback={…}>{(value) => …}</Load.Stream>` awaits one promise behind its own Suspense boundary; a resolved value renders in the shell with no boundary at all, so the same call site works either way.
|
|
274
346
|
- Avoid hooks. `useState` is for modal-open, tab, draft-input, and drag state only — never for server data. `useEffect` must be a genuine effect such as subscribe-with-cleanup or one-shot init. Prefer `Tab` over a `useState` mode switch. `.Template.tsx` files contain zero `useState`.
|
|
275
|
-
- Forms are entirely store-driven: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`, the setter passed by reference. Always use `Field.*`, never a bare `<input>` for a model field. Nested rows use `st.do.writeOnX("payments.3.name", v)` plus the generated `add<Field>OnX` / `sub<Field>OnX`.
|
|
347
|
+
- Forms are entirely store-driven: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`, the setter passed by reference. Always use `Field.*`, never a bare `<input>` for a model field. Nested rows use `st.do.writeOnX("payments.3.name", v)` plus the generated `add<Field>OnX` / `sub<Field>OnX`. **Passing the setter by reference is also what makes the framework emit `data-akan-action` / `data-akan-state`** on the control — the annotation an in-page agent, an E2E selector, and an external browser agent all read. Wrapping it in an inline arrow (`onChange={(v) => st.do.setFieldOnX(v)}`) silently drops that: a closure the caller wrote says nothing about what it does. Never hand-write a `data-akan-*` attribute.
|
|
276
348
|
- Read with `st.use.*` and write with `st.do.*`. Client components do not call `fetch.*`.
|
|
349
|
+
- **An edit shell recovers its own form — never persist form values yourself.** `Load.Edit`, `Model.EditModal` and
|
|
350
|
+
`Model.New` save the whole `<model>Form` as the user types and offer it back on the next open, so a modal closed
|
|
351
|
+
by accident, a route change, or a killed app costs nothing. The scope is the record id for an edit and the seed
|
|
352
|
+
plus the route for a new form, both keyed to the signed-in user, so another record or another parent never loads
|
|
353
|
+
someone else's half-finished form. A new form's draft is applied on open with a "start over" chip; an edit whose
|
|
354
|
+
record moved in the meantime is *not* applied, and asks. Turn it off with `draft={false}`, or name the scope
|
|
355
|
+
yourself with `draft="<scope>"` when the context is in neither the id nor the seed. `field.secret` and
|
|
356
|
+
`field.hidden` values are never saved. The old per-field `cache` / `cacheKey` props are deprecated and store
|
|
357
|
+
nothing — they covered five control types, keyed on the translated label, and restored over server data.
|
|
277
358
|
- Static class strings stay plain strings. Reach for `cn` only for a conditional or to merge an incoming `className`, and merge the caller last: `cn("base classes", cond && "extra", className)`. `cn` comes from `akanjs/client` (token-aware tailwind-merge) and is the only class-combining function — no `clsx` (removed), no raw `twMerge` imports, no object syntax (`{ x: cond }` → `cond && "x"`).
|
|
278
359
|
- Multi-slot components take extra named props (`wrapperClassName`, `bodyClassName`), never a `classNames` object.
|
|
279
|
-
- Use daisyUI semantic tokens with opacity modifiers (`text-base-content/60`, `border-base-content/10`, `bg-base-100/70`, `bg-primary/10`). Never use `dark:` — theming is the daisyUI theme block in `page/*/styles.css`. Raw hex belongs only in marketing surfaces; match the neighbouring files.
|
|
280
360
|
- Hoist enum→class lookups to a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`. Do not use `Record<...>`. Escalate the map to `webkit/` when a second module needs it.
|
|
281
361
|
- Use `<Link>` from `akanjs/ui` for internal navigation; `<a>` only for `mailto:` and external links.
|
|
282
362
|
|
|
@@ -304,23 +384,26 @@ Full version with code, the `Tab` composition example, and a review checklist: `
|
|
|
304
384
|
|
|
305
385
|
**`<model>.constant.ts`** — five classes in order with one blank line between them and `enumOf("camelName", [...] as const)`
|
|
306
386
|
classes above: `XInput → XObject → LightX → X → XInsight`. Write `XInsight` even when it is empty. Put display and
|
|
307
|
-
predicate logic on `LightX` (`isNew()`, `canWrite(user?)`, `formatTimes()
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
387
|
+
predicate logic on `LightX` (`isNew()`, `canWrite(user?)`, `formatTimes()`) — the Light class is the one both server
|
|
388
|
+
and client hold, so shared logic belongs there instead of in a util module. **This is the most commonly missed rule
|
|
389
|
+
in the codebase.** Collection-level helpers go `static` on the full model. Give any field whose business meaning is
|
|
390
|
+
not obvious a short trailing comment.
|
|
391
|
+
|
|
392
|
+
**A model instance's `Date` fields are prototype accessors, not own properties.** `user.createdAt` is still a `Dayjs`,
|
|
393
|
+
built on first read from a native `Date` the instance holds under a symbol. So `Object.keys(user)` and `{ ...user }`
|
|
394
|
+
do **not** include them, while `"createdAt" in user`, `for...in`, `JSON.stringify(user)`, `plainFieldsOf(user)`
|
|
395
|
+
(`akanjs/common`), `immerify`, and `deepObjectify` all do. To copy a model, `new cnst.X().set(user)`, never a spread.
|
|
311
396
|
|
|
312
397
|
**`<model>.document.ts`** — fixed order: `XFilter extends from(...)` → `X extends by(...)` → `XModel extends into(...)`,
|
|
313
398
|
with `sort: {}` always present. Chain methods validate → mutate → `return this`, and never `save()`; the caller saves,
|
|
314
399
|
so chains compose (`org.removeUser(id).removeInvite(id).save()`). Put a one-line comment above each stating the
|
|
315
400
|
transition. Atomic counters live on the Model class with the updater-callback form, returning `!!modifiedCount`.
|
|
316
401
|
Indexes and derived totals go in `static override _onSchema`, not in the service. **Removal is always soft** — the
|
|
317
|
-
model facade's `removeMany(query)` and the store's `removeManyByQuery` stamp `removedAt` like `remove(id)` does; the
|
|
318
402
|
framework has no hard delete for a model table, and `delete` is deliberately left unused so it can mean one later.
|
|
319
|
-
The facade
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
hooks either, so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`.
|
|
403
|
+
The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`); only the
|
|
404
|
+
count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)` and
|
|
405
|
+
`removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no hooks,
|
|
406
|
+
so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`.
|
|
324
407
|
|
|
325
408
|
**`<model>.service.ts`** — keep methods to a few lines: load → chain → `return await ….save()`. Write `return await`
|
|
326
409
|
explicitly in tail position; do not "optimize" it away. Side effects belong in `override async _preUpdate` /
|
|
@@ -336,18 +419,13 @@ stores need none, because state and CRUD actions are generated. The body is thre
|
|
|
336
419
|
`this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
|
|
337
420
|
`this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
|
|
338
421
|
through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
|
|
339
|
-
nothing**
|
|
340
|
-
|
|
341
|
-
`import type { RootStore } from "../st"`** — it crashes `akan build` with a Bun SSR segfault.
|
|
422
|
+
nothing** (`no-return-in-store-action.grit`). **Never `import type { RootStore } from "../st"`** — it crashes
|
|
423
|
+
`akan build` with a Bun SSR segfault.
|
|
342
424
|
|
|
343
425
|
**`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
|
|
344
426
|
`.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
|
|
345
427
|
Name every argument in `.arg()`, including framework-supplied `skip` / `limit` / `sort`. Use `modelDictionary`,
|
|
346
428
|
`scalarDictionary`, or `serviceDictionary` to match the module kind.
|
|
347
|
-
**`.store()` sits between `.endpoint()` and `.error()` and is the one optional stage** — omit it entirely rather
|
|
348
|
-
than writing it empty. It names custom store actions (labels and `.desc()` only, no `.arg()`), and it is only
|
|
349
|
-
needed where inheriting would be wrong: an action named after the endpoint it calls already reads as that
|
|
350
|
-
endpoint's `.desc()`, which is most of them. `akan.agent.missing-store-description` names the rest.
|
|
351
429
|
|
|
352
430
|
**`<module>.abstract.md`** — a title line, one declarative sentence naming what the module owns, a `## Rules` list of
|
|
353
431
|
two to five invariants the code cannot show, and an optional workflow arrow chain
|
|
@@ -365,17 +443,34 @@ workflow changes.
|
|
|
365
443
|
|
|
366
444
|
### Guards And Transports
|
|
367
445
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
-
|
|
446
|
+
Full contract — credential handshake, room revalidation, socket cleanup scoping: `get_guideline` with
|
|
447
|
+
`transportRule`, or `akan guideline show transportRule`.
|
|
448
|
+
|
|
449
|
+
- Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` instead of
|
|
450
|
+
branching on `getHttpContext()` / `getWebSocketContext()`, and keep them side-effect free and safe to re-run —
|
|
451
|
+
a pubsub room's guards are re-run whenever the socket's credential changes.
|
|
452
|
+
- Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is
|
|
453
|
+
unguarded unless it declares its own `guards` in its signal option.
|
|
454
|
+
- **Never read the caller's IP off the socket or the request peer — take `.with(Ip)`** (`context.getClientIp()`
|
|
455
|
+
inside a guard or middleware). Behind a federation gateway, `ws.remoteAddress` and the child's own peer are the
|
|
456
|
+
*gateway* (`127.0.0.1`) for every caller and for the whole life of every socket. `Ip` reads what a proxy
|
|
457
|
+
recorded, is unwrapped from its `::ffff:` form, and is `null` rather than a placeholder when genuinely unknown.
|
|
458
|
+
- **Every socket carries a `socketId`, and only the framework mints one** — read it off the `Ws` internal arg
|
|
459
|
+
(`.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`). Never mint your own from `ws.data`: the room
|
|
460
|
+
bookkeeping keys on the framework's id and a second one fails to match it silently. It identifies a
|
|
461
|
+
**connection**, not a caller, so per-user state keys on the account.
|
|
462
|
+
- **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it**
|
|
463
|
+
— a room for a `pubsub` subscribe, the socket for a `message` handler. Cleanup that must happen either way
|
|
464
|
+
registers for both.
|
|
372
465
|
|
|
373
466
|
### Authorization Defaults
|
|
374
467
|
|
|
375
468
|
- **Every `slice()` takes an explicit `{ guards: {…} }` second argument, and `root:` is always `Admin`.**
|
|
376
469
|
- **Every custom `mutation` / `query` / `message` names its own `guards: [...]` array.** Never rely on the slice default. `Public` belongs on a slice `get:`, never on a mutation.
|
|
470
|
+
- **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.
|
|
377
471
|
- 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.
|
|
378
472
|
- 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.
|
|
473
|
+
- **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.
|
|
379
474
|
- The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`. Never trust a client-supplied id.
|
|
380
475
|
- 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.
|
|
381
476
|
- Services re-check ownership even when a guard already gated the call — two independent gates.
|
|
@@ -385,7 +480,24 @@ workflow changes.
|
|
|
385
480
|
|
|
386
481
|
- `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
|
|
387
482
|
- Numbers must use `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
|
|
388
|
-
- `Upload` is valid only inside a mutation flagged for file upload: `mutation([cnst.File], { fileUpload: true }).body("files", [Upload])
|
|
483
|
+
- `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.
|
|
484
|
+
- Bytes are `Binary`, never `Any` — see Scalar & Field Type Reference.
|
|
485
|
+
|
|
486
|
+
### Binary Pubsub And Mutation Verbs
|
|
487
|
+
|
|
488
|
+
Full contract — backpressure modes, the `Any`-carrying-bytes fallback, multi-verb paths: `get_guideline` with
|
|
489
|
+
`transportRule`.
|
|
490
|
+
|
|
491
|
+
- **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON envelope and the base64 a
|
|
492
|
+
JSON wire would need; the client's subscribe callback receives a `Uint8Array`. The optimization applies only
|
|
493
|
+
when the **whole** return is `Binary`. A declared `Binary` room **coalesces under backpressure** — keeping only
|
|
494
|
+
the newest frame — which is what telemetry and video want; declare `{ backpressure: "queue" }` when the frames
|
|
495
|
+
are a sequence a subscriber must see in full. A `pubsub(Any)` carrying bytes is framed too and warns once;
|
|
496
|
+
declare `Binary`.
|
|
497
|
+
- **A `mutation` is `POST`.** `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several
|
|
498
|
+
verbs; two endpoints claiming the same path **and** verb fail the boot. Reach for it only when a foreign wire
|
|
499
|
+
protocol forces the verb — Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
|
|
500
|
+
whatever is declared.
|
|
389
501
|
|
|
390
502
|
### Reserved Endpoint Names
|
|
391
503
|
|
|
@@ -394,51 +506,63 @@ workflow changes.
|
|
|
394
506
|
|
|
395
507
|
### Slices, Queries, and Hydration
|
|
396
508
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
- **
|
|
509
|
+
Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `labelOf`, and the `q.exists` /
|
|
510
|
+
`q.missing` / `q.empty` split: `get_guideline` with `queryRule`, or `akan guideline show queryRule`.
|
|
511
|
+
|
|
512
|
+
- **The root slice (`""`, generated by `slice()`) takes `queryKey` and `args`, not a query.** `initX("byOwner",
|
|
513
|
+
[ownerId])` names one of the model's own filters and passes that filter's args; no key at all is the `any`
|
|
514
|
+
filter every model carries. A raw query descriptor could not survive the query string — `String(value)` sends
|
|
515
|
+
`[object Object]`. It is an admin API: `root:` is always `Admin`.
|
|
516
|
+
- A slice's `exec` returns a `QueryOf` (an opaque query descriptor); you **cannot** chain `.sort()`/`.limit()` on
|
|
517
|
+
it. Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })`. For a
|
|
518
|
+
chainable builder use the model facade's `findMany`/`findOne`.
|
|
519
|
+
- **A slice list is either one window or an accumulated one.** `setPageOf<Model>` swaps the window;
|
|
520
|
+
`loadMoreOf<Model>()` appends the rows after the ones loaded and takes **no page number** — it skips by
|
|
521
|
+
`<model>List.length`, so the offset cannot drift from what is on screen when a live insertion moves the server's
|
|
522
|
+
baseline, and `pageOf<Model>` stays 1, which is what keeps live placement (first page only) working past the
|
|
523
|
+
first "more". `isCumulativeOf<Model>` is the mode.
|
|
524
|
+
- **"Is there more" is `hasMoreOf<Model>`, never `count > page * limit`.** It comes from the length of the batch
|
|
525
|
+
the server returned, so it survives `{ insight: false }` and cannot drift from a count a live event moved.
|
|
526
|
+
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`);
|
|
527
|
+
client fetch results are raw `GetStateObject` plain data, functions stripped.
|
|
401
528
|
- Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
|
|
402
|
-
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
|
|
403
|
-
query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
|
|
404
|
-
model that carries no removal side effect; otherwise remove documents one at a time.
|
|
529
|
+
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
|
|
530
|
+
are query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
|
|
531
|
+
them on a model that carries no removal side effect; otherwise remove documents one at a time.
|
|
405
532
|
- **`update<Filter>` / `updateOne<Filter>` are chains, not calls:** `await updateInRoot(rootId).set({ status:
|
|
406
|
-
"archived" })`. The patch cannot trail the filter args
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
- **
|
|
413
|
-
|
|
414
|
-
|
|
533
|
+
"archived" })`. The patch cannot trail the filter args, so it lands on a terminal `.set()`; building the chain
|
|
534
|
+
touches nothing.
|
|
535
|
+
- `removeOne` / `updateOne` hit the **newest** match, always, and report only counts — they are for "there is at
|
|
536
|
+
most one of these", not for claiming the next item off a queue.
|
|
537
|
+
- **A filter may not be keyed after its own model.** A filter `chat` on model `chat` would silently swap the
|
|
538
|
+
single-document `removeChat`/`updateChat` for a hookless query-level one; it throws at boot instead.
|
|
539
|
+
- **"Has no value" is `q.empty`, never `q.missing`.** `q.missing` is the key being *absent from the stored JSON*,
|
|
540
|
+
which a document written once and saved again no longer is. Reach for it only to find rows written before the
|
|
541
|
+
field was declared.
|
|
415
542
|
|
|
416
543
|
### Text Search In A Filter — `q.search()`
|
|
417
544
|
|
|
545
|
+
Full contract — relevance ordering, `columns` / `weights`: `get_guideline` with `queryRule`.
|
|
546
|
+
|
|
418
547
|
- Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
|
|
419
548
|
q.search(text, { prefix: true }))`. The generated `listBySearch` / `countBySearch` / `queryBySearch` /
|
|
420
549
|
`insightBySearch` come for free — you do **not** need a slice to make search usable.
|
|
421
550
|
- **Only add a search slice when the model's data is safe to enumerate.** A filter is server-side; a slice is a
|
|
422
|
-
client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk
|
|
423
|
-
table. Leave that decision to the mounting app.
|
|
551
|
+
client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk
|
|
552
|
+
the table. Leave that decision to the mounting app.
|
|
424
553
|
- `q.search()` compiles to a JOIN, not a WHERE fragment, so it **must sit at an AND position**. Nesting it under
|
|
425
|
-
`q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery
|
|
426
|
-
takes no join, so ignoring the node would silently widen the write to every other matching row.
|
|
554
|
+
`q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery`.
|
|
427
555
|
- Blank or whitespace-only input matches **nothing**. Never "fix" that into a passthrough: a passthrough turns a
|
|
428
556
|
search endpoint into a full listing.
|
|
429
|
-
- Order by relevance with the built-in `relevance` sort key.
|
|
430
|
-
|
|
431
|
-
compiler's own, not a model-defined default — redefining `latest` on the model does not change it.
|
|
432
|
-
- **A slice endpoint never reaches "unspecified".** The resolver fills `latest` before the query is built, so a
|
|
433
|
-
client asking for the score order has to name `relevance`; leaving `sort` off gets `latest`, not relevance.
|
|
434
|
-
- Scope a search with `columns` (`q.search(text, { columns: ["title"] })`) and re-weight with `weights`, a tuple of
|
|
435
|
-
finite numbers positional over `["title", "desc", "tag", "filter"]`.
|
|
557
|
+
- Order by relevance with the built-in `relevance` sort key. A slice endpoint never reaches the unspecified
|
|
558
|
+
fallback — leaving `sort` off gets `latest`, so a client wanting score order has to name `relevance`.
|
|
436
559
|
|
|
437
560
|
### Service / Signal Injection
|
|
438
561
|
|
|
439
562
|
- 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`).
|
|
440
563
|
- 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.
|
|
441
564
|
- 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.
|
|
565
|
+
- **`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.
|
|
442
566
|
|
|
443
567
|
### Adapters — `adapt()` And `plug()`
|
|
444
568
|
|
|
@@ -456,8 +580,8 @@ export class AdminNoti extends adapt("adminNoti" as const, ({ use, env, plug, me
|
|
|
456
580
|
- Inject it with `plug(TheClass)` from a service or from another adapter. Destructure only the injectors you use, and write the registration key `as const`.
|
|
457
581
|
- **Do not register an `adapt()` class in `lib/option.ts`.** It self-registers, and `plug(Class)` uses the class itself as the token. `option.ts` is now only for legacy constructor-style adapters and for widening the options type.
|
|
458
582
|
- `this.logger` is provided; never construct a `Logger` inside an `adapt()` class. Lifecycle work goes in `override async onInit()`.
|
|
459
|
-
- **`adapt()` is for singletons only.** A per-use value object stays a plain class you `new` at the call site.
|
|
460
|
-
- **Legacy shape — recognise it, do not copy it.** Plain classes with `constructor(options: XOptions)`, registered in `lib/option.ts`
|
|
583
|
+
- **`adapt()` is for singletons only.** A per-use value object stays a plain class you `new` at the call site. If there is not exactly one per process that a service needs injected, it is a plain class.
|
|
584
|
+
- **Legacy shape — recognise it, do not copy it.** Plain classes with `constructor(options: XOptions)`, registered in `lib/option.ts` and injected with `use<T>()`, still work. Migrate one to `adapt()` only when you are already changing it.
|
|
461
585
|
|
|
462
586
|
Conventions that hold for both shapes:
|
|
463
587
|
|
|
@@ -466,7 +590,7 @@ Conventions that hold for both shapes:
|
|
|
466
590
|
- Resolve secrets as `process.env.X ?? options.x ?? deterministicGenerator(...)` **inside a function**, never at module scope.
|
|
467
591
|
- Extend a function by appending an optional trailing parameter with a default, never by changing arity.
|
|
468
592
|
- Parameters: up to three required primitives positional; optional flags in a trailing `{ … } = {}`; four or more parameters, or any two same-typed strings, in one named destructured object.
|
|
469
|
-
- Release locks in `finally`. Load heavy optional dependencies through a module-level memoized promise (`
|
|
593
|
+
- Release locks in `finally`. Load heavy optional dependencies through a module-level memoized promise (`puppeteerLoad ??= import("puppeteer")`).
|
|
470
594
|
|
|
471
595
|
### Error Placement
|
|
472
596
|
|
|
@@ -474,147 +598,115 @@ Conventions that hold for both shapes:
|
|
|
474
598
|
- Best-effort code returns a sentinel (`null`, `undefined`, `[0, 0]`, `{}`). There are no Result/Either wrappers.
|
|
475
599
|
- `try/catch` is rare and always converts an exception into a decision, never swallows one. Guards catch → `logger.warn` → `return false`; adapters catch → `logger.error` → `return null`; UI uses `try/finally` to reset a spinner. A bodyless `catch {}` is acceptable only with a one-line reason.
|
|
476
600
|
- Store actions do not `try/catch` — let the framework toast the `Err`. Client-side validation failure is `msg.error("<key>")` plus an early return, never a throw.
|
|
601
|
+
- **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.
|
|
477
602
|
|
|
478
603
|
### MCP Exposure
|
|
479
604
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
`
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
- **
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
- **
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
`
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
- **
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
are derived from the catalogue, so a server with no prompts does not advertise `prompts`.
|
|
586
|
-
- **An expired or wrongly-audienced bearer token is refused up front**, so an agent is told to authenticate rather
|
|
587
|
-
than that the tool does not exist. Its **signature is not checked** — that needs your app's own secret — so a
|
|
588
|
-
token signed wrong, like an opaque one, still degrades to an anonymous caller.
|
|
589
|
-
- **Resource URIs**: `akan://<model>/{id}`, `akan://<model>/light/{id}`, `akan://<model>/list` for the model's own
|
|
590
|
-
list, and `akan://<model>/list/<sliceKey>` for a slice's. The root list takes no third segment on purpose — any
|
|
591
|
-
token there is one a slice could also be named. **Those four are the whole set**, so `mcp: { resource: true }`
|
|
592
|
-
is honoured only on the generated reads: a custom endpoint keeps its tool, gets no resource template, and is
|
|
593
|
-
named in the boot log saying so.
|
|
594
|
-
- **The catalogue is one language**, `en` unless `language` says otherwise: it is built once at boot and cached by
|
|
595
|
-
clients, so there is no `Accept-Language` negotiation.
|
|
596
|
-
|
|
597
|
-
**`prompt()`** is invoked by the *user* — a client renders it as a slash command — not chosen by the model. `exec`
|
|
598
|
-
returns `PromptMessage[]`, or a bare string that is wrapped into one user message; build them with `Msg.user` /
|
|
599
|
-
`Msg.assistant` / `Msg.link` / `Msg.resource` / `Msg.image` / `Msg.imageOf`. It takes `.param()` and `.search()`
|
|
600
|
-
only, because `prompts/get` sends a flat string map. **An embedded payload is masked by the model you name** —
|
|
601
|
-
`Msg.resource(uri, task, { model: cnst.LightTask })`, or `Msg.mask(cnst.LightTask, task)` for one piece of an
|
|
602
|
-
assembly. Taking the model as an argument is what makes a `{ ...doc }` spread maskable, since that and `toJSON()`
|
|
603
|
-
arrive with the class already gone; a value with no model named whose `hidden`/`secret` fields are populated is
|
|
604
|
-
**refused**, one level into a plain object too. **A `prompt` is also mounted as a
|
|
605
|
-
plain HTTP `GET` whether or not you enabled MCP**, because that route is what lets a web UI preview one — and it
|
|
606
|
-
is in your OpenAPI document like any other `GET`, answering the one fixed `PromptMessage[]` shape. MCP exposure
|
|
607
|
-
gates the catalogue, not the surface, so guard it
|
|
608
|
-
like any other read — and a prompt declaring no
|
|
609
|
-
`guards` at all is named in the boot log, while an explicit `[Public]` is a decision and stays quiet. Every `Msg` builder takes
|
|
610
|
-
optional `annotations` last (`audience`, `priority` 0..1, `lastModified`) — give the instruction a high `priority`
|
|
611
|
-
and its attachments a low one, or a client with a full window drops blocks by position and keeps the attachment
|
|
612
|
-
over the ask.
|
|
613
|
-
|
|
614
|
-
**`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
|
|
615
|
-
frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
|
|
616
|
-
websocket, and in tests. `McpProgress.streaming` says whether anyone is reading, for a report whose message
|
|
617
|
-
costs something to assemble.
|
|
605
|
+
Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
|
|
606
|
+
follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An
|
|
607
|
+
endpoint that declares a real guard is published; one that declares none is refused, and so is a mutation whose
|
|
608
|
+
only guard is `Public`. The guards are already the authorization decision, so a second switch would say nothing
|
|
609
|
+
they do not — while guaranteeing that every endpoint added later is invisible to agents until somebody remembers
|
|
610
|
+
it.
|
|
611
|
+
|
|
612
|
+
- **`mcp: false` keeps an endpoint off the shelf without touching its guards.** Guards answer "may an agent call
|
|
613
|
+
this"; they cannot answer "does this belong on a shelf", and a step of a UI-driven state machine
|
|
614
|
+
(`requestPhoneCodeForSignin`) is perfectly guarded and still a mistake for a model to reach. Write it in the
|
|
615
|
+
signal option (`mutation(Boolean, { guards: [Every], mcp: false })`, `init({ guards: [SignedIn], mcp: false })`),
|
|
616
|
+
or as an `mcp` map on `slice()` that **mirrors the `guards` map key for key** (`mcp: { cru: false }`) — root
|
|
617
|
+
slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, and it is
|
|
618
|
+
curation, not authorization: HTTP serves the endpoint exactly as before.
|
|
619
|
+
- **Narrow by cost, and read the boot log first.** MCP forbids a `$ref` across entries, so every entry inlines the
|
|
620
|
+
schema of every model it mentions and the listing is re-sent whole to every agent that connects. By default a
|
|
621
|
+
response schema names a nested model instead of inlining it (`outputSchema: "shallow"`; `"full"` restores the
|
|
622
|
+
closure, `"none"` drops the schema and keeps the text block) and a request schema asks for a relation's id, which
|
|
623
|
+
is what the wire carries. `MCP catalogue: … · listing 214KB` plus a per-signal `MCP catalogue cost:` line says
|
|
624
|
+
where the rest went; `mcp: { cru: false }` on a model is the next lever. `outputSchema` moves wire bytes and client
|
|
625
|
+
memory only — the model receives names, descriptions and input schemas (measured: 219 tools ≈ 41k tokens either
|
|
626
|
+
way) — so tool count and the input model behind `create*` / `update*` are what cost context. In one line:
|
|
627
|
+
`Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost the model
|
|
628
|
+
tokens, `outputSchema` for nothing that reaches the model.
|
|
629
|
+
- Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
|
|
630
|
+
allowedOrigins, pageSize, language, outputSchema, rateLimit, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
|
|
631
|
+
`main.ts`**. Each field has an `AKAN_MCP_*` env spelling. `AKAN_MCP_READONLY=true` is the read-only-deployment
|
|
632
|
+
valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off. Every call that executes an
|
|
633
|
+
endpoint is rate-limited per caller (120 a minute, 8 in flight, per process; 429 + `Retry-After`) — `rateLimit`
|
|
634
|
+
/ `AKAN_MCP_RATE_LIMIT` tunes or disables it.
|
|
635
|
+
- **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
|
|
636
|
+
beside `/mcp` — `/.well-known/oauth-authorization-server`, `/oauth/authorize`, `/oauth/token`, `/oauth/register`
|
|
637
|
+
(RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
|
|
638
|
+
names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
|
|
639
|
+
`aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
|
|
640
|
+
judge it unchanged; there is no scope. Per-app knobs live in `env.server.*` under `oauth` (`consentPath` and
|
|
641
|
+
`signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `clientIdMetadata`,
|
|
642
|
+
`enabled`). `/mcp` reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` is
|
|
643
|
+
mandatory outside `local`, because the derived fallback is forgeable from three public names. A grant is revoked
|
|
644
|
+
whole — `POST /oauth/revoke` (RFC 7009) by the client, `fetch.revokeOAuthConnection(sessionId)` by the account's
|
|
645
|
+
owner, `fetch.listOAuthConnections()` to see them — and a revoked access token is refused at its next call.
|
|
646
|
+
- **A person may, a model may not.** An agent's token names its `client_id` and `aud`; a browser session has
|
|
647
|
+
neither, and `context.origin` is `"mcp"` for a call through the MCP endpoint. `guards: [Every, Person]` refuses a
|
|
648
|
+
model and, because `Person` declares `static agents = false`, takes the entry out of the MCP catalogue document
|
|
649
|
+
itself (named in the boot log like `mcp: false`); `.with(AgentCall)` hands an endpoint the same verdict as a
|
|
650
|
+
boolean to narrow what the call sets in motion; `isAgentCall(context)` is the function behind both
|
|
651
|
+
(`@libs/shared/srvkit`). Never sniff `aud` through a cast.
|
|
652
|
+
- **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
|
|
653
|
+
guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
|
|
654
|
+
generated `light<Model>` read. An `enumOf` argument is checked against its values on every path. **Every refusal
|
|
655
|
+
is named in the boot log**, along with every published entry missing a dictionary `.desc()` — an agent picks a
|
|
656
|
+
tool by its description.
|
|
657
|
+
- A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
|
|
658
|
+
generalized. Never make either message more helpful — the difference is what enumerates your private surface.
|
|
659
|
+
- **A `field.visual` field is stripped from every MCP result** and from the readable schema so the two agree.
|
|
660
|
+
- **A structured result ships twice by default** — as `structuredContent` and as the same JSON in the text block.
|
|
661
|
+
`option.setMcp({ legacyTextBlock: false })` leaves a pointer there instead.
|
|
662
|
+
- **A prompt is a screen: `page().prompt(name, description)`.** There is no `prompt()` on `endpoint()`.
|
|
663
|
+
`prompts/get` runs the page's body in the RSC worker under the caller's token — nothing is rendered — and answers
|
|
664
|
+
the description, one `resource` per `fetch.*` the page made (masked by the endpoint's return model, addressed by
|
|
665
|
+
its `akan://` uri, one document once whatever shapes read it) and the published tools of the modules it fetched
|
|
666
|
+
from, minus what is already attached; lists — only lists — are cut to `promptBudget`. A guard refusing a query
|
|
667
|
+
inside the body reads as the screen refusing the account, the same way a redirect does. A
|
|
668
|
+
required argument left out answers a pointer to the `<model>List…` tool, never a guess. The description is the
|
|
669
|
+
only instruction, in English — `<Agent.Guide>` never reaches MCP, and the in-page chat lists no page prompts.
|
|
670
|
+
|
|
671
|
+
Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
|
|
672
|
+
`McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
|
|
673
|
+
|
|
674
|
+
## In-Page Agent
|
|
675
|
+
|
|
676
|
+
Every akan app can host a component-level agent that reads the rendered screen and drives it. **A component
|
|
677
|
+
declaration is the surface, exactly**: `st.tool` publishes one action, and `st.use` / `st.sel` / `st.ref` make one
|
|
678
|
+
store key readable while the reading component is mounted. Nothing is derived from a store class — a lever the
|
|
679
|
+
screen does not offer the user is not one an agent may pull in their place. The React core is the `use-agentic`
|
|
680
|
+
package; apps and libs never import it directly (`no-import-external-library`) — everything reaches them through
|
|
681
|
+
`st.*` and `akanjs/ui`.
|
|
682
|
+
|
|
683
|
+
Read the full contract before building an agent surface — the chat's own options, `st.tool` / `st.expose` /
|
|
684
|
+
`st.useState` in full, zones and namespaces, attachments and speech, slash commands, transcript compaction, and
|
|
685
|
+
the built-in tool semantics: `get_guideline` with `agentRule`, or `akan guideline show agentRule`. What follows is
|
|
686
|
+
what every developer must know even when not building one.
|
|
687
|
+
|
|
688
|
+
- **Mount `<Agent.Chat />` once in a layout.** The endpoint is a stateless relay that **never executes tools**:
|
|
689
|
+
every tool runs in the caller's own browser session, gated by guards and the approval card. Its guard is
|
|
690
|
+
`AgentRelayAccess`, which refuses every call until the app names a guard of its own —
|
|
691
|
+
`option.setAgentAccess(SignedIn)`. **The LLM is configured in `option.ts`, never through the environment**
|
|
692
|
+
(`option.setLlm({ apiKey, model, host })`).
|
|
693
|
+
- **Declare the tool beside the control that already does it**, never as a separate surface:
|
|
694
|
+
`st.tool("x").desc("…").arg("id", ID).exec(fn)` returns the callable to hand to `onClick` — one handler for the
|
|
695
|
+
person and the agent. Publish it only where the screen already renders the control; a falsy name declares the
|
|
696
|
+
tool without publishing it, and a `disabled` control publishes nothing.
|
|
697
|
+
- **Forms publish themselves**: a `Field.*` / `Input.*` / `Select` / `Switch` handed `onChange={st.do.setXOnY}`
|
|
698
|
+
**by reference** publishes that setter, and `st.use.yForm()` adds `fillYForm(patch)`. An inline arrow publishes
|
|
699
|
+
nothing — normalize with the control's `transform` prop, and multi-write with a `_postSet<Field>` store method.
|
|
700
|
+
This is the same rule `no-unpublished-form-setter.grit` enforces.
|
|
701
|
+
- **Reading is per key, not per store.** `st.use.x({ agent: false })` keeps a subscribed key off the surface; a key
|
|
702
|
+
no component reads is unreadable. A value with populated `hidden`/`secret` fields is refused at read.
|
|
703
|
+
- **Return what answers the question, not the record.** A tool's value is capped at 20,000 characters before it
|
|
704
|
+
enters the transcript, and once there it rides *every* later turn. A field that is bulky and useless to a model
|
|
705
|
+
is fixed once at the model instead: `field.visual(String)`.
|
|
706
|
+
- **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or page — the render
|
|
707
|
+
tree is the cascade. It is a component, not a `pageConfig` field, and `*.abstract.md` is never served to agents.
|
|
708
|
+
- **Model-facing text is English, always** — every `.desc()`, `instructions`, Guide text. The `l()` rule covers
|
|
709
|
+
strings a *user* reads.
|
|
618
710
|
|
|
619
711
|
## Scalar Modeling (`**/*.constant.ts`)
|
|
620
712
|
|
|
@@ -625,147 +717,148 @@ costs something to assemble.
|
|
|
625
717
|
- Follow the established model layering pattern in this order: `Input`, `Object`, `Light<Model>`, full `<Model>`, and `<Model>Insight`. Write all five, and write `<Model>Insight` even when it is empty.
|
|
626
718
|
- Put display and predicate logic on the `Light<Model>` class rather than in a util module — see Module File Playbook.
|
|
627
719
|
- Defaults are a literal for scalars and a thunk for anything constructed. Arrays are `field([T])`; optional is the postfix `.optional()`.
|
|
720
|
+
- **`field.visual(T)` is a field the page renders and an agent never sees** — a blur placeholder, a rendered HTML body, a serialized geometry. It stays an ordinary stored `property` (persistence, search, forms and the page response untouched) and is stripped wherever a value is masked for an AI caller: every in-page-agent read and every MCP result, along with the MCP readable schema. Unlike `hidden`/`secret` it is cost, not secrecy — nothing is refused over one. Reach for it whenever a field is bulky and useless to a model; that is cheaper than every tool learning to avoid it.
|
|
628
721
|
|
|
629
722
|
### Scalar & Field Type Reference
|
|
630
723
|
|
|
631
|
-
- **Import from `akanjs/base`** (real classes/helpers, not globals): `Int`, `Float`, `ID`, `Any`, `Upload`, `enumOf`, and the `dayjs` factory. There is **no `JSON` scalar** — use `Any` for open/flexible payloads.
|
|
724
|
+
- **Import from `akanjs/base`** (real classes/helpers, not globals): `Int`, `Float`, `ID`, `Any`, `Binary`, `Upload`, `enumOf`, and the `dayjs` factory. There is **no `JSON` scalar** — use `Any` for open/flexible payloads.
|
|
632
725
|
- **Use the JS globals directly (no import needed)**: `String`, `Boolean`, `Date`. They are monkey-patched to behave like scalars, so `field(String)` typechecks.
|
|
633
726
|
- **`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.
|
|
634
727
|
- Runtime resolution of every scalar (globals included) goes through `PrimitiveRegistry` by `refName` (`pkgs/akanjs/base/primitiveRegistry.ts`).
|
|
728
|
+
- **`Binary` is raw bytes on the wire, and never a model field.** It is `Uint8Array` on both sides — a Node
|
|
729
|
+
`Buffer` is one, so a server handler may pass one straight in — and it accepts base64 in either direction, so
|
|
730
|
+
the same declaration serves a JSON body and a binary frame. It is **not storable**: the class build refuses
|
|
731
|
+
`field(Binary)` naming the `File` model instead, because every non-base field lives in the `_doc` JSON column
|
|
732
|
+
and bytes would sit there as base64 and ride every read of the row. Never send bytes as `Any` — `Any` passes a
|
|
733
|
+
`Buffer` through untouched and `JSON.stringify` then spells it `{ type: "Buffer", data: number[] }`, which
|
|
734
|
+
`JSON.parse` never restores; the result is 3.6x the wire and a shape that only breaks at the first byte-offset
|
|
735
|
+
read. MCP refuses a `Binary` return for the same reason it refuses `Any`.
|
|
635
736
|
|
|
636
737
|
### Text Search Fields — the `text` role
|
|
637
738
|
|
|
638
|
-
|
|
639
|
-
|
|
739
|
+
Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH_ENABLED`: `get_guideline` with
|
|
740
|
+
`queryRule`.
|
|
741
|
+
|
|
742
|
+
- A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and
|
|
743
|
+
likewise `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model
|
|
744
|
+
switch. Declaring roles is all the wiring there is.
|
|
640
745
|
- Pick the role by what the value *is*, because `bm25` weights them positionally (`title` 10, `tag` 3, `desc` 1,
|
|
641
746
|
`filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
|
|
642
|
-
scoping value (status, owner, role) that must be matchable but must never outrank a real title hit.
|
|
643
|
-
|
|
644
|
-
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
{ text: "tag" })`); an array of objects indexes by leaf key, including an array leaf (`works[*].tags`). A field
|
|
650
|
-
inside a `Map` indexes nothing: there is no fixed path to extract it from.
|
|
651
|
-
- Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
|
|
652
|
-
because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
|
|
653
|
-
path.
|
|
747
|
+
scoping value (status, owner, role) that must be matchable but must never outrank a real title hit. `thumb` is
|
|
748
|
+
mirrored for rendering a hit and is **not** indexed — never expect it to match.
|
|
749
|
+
- **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
|
|
750
|
+
mirror is plaintext and an indexed secret would leak through search. The same refusal throws at runtime as a
|
|
751
|
+
backstop, including for a `text` field *underneath* one of those. Do not work around either.
|
|
752
|
+
- The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
|
|
753
|
+
indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
|
|
654
754
|
- Search runs on sqlite/libsql only. `q.search()` against Postgres throws, loudly, rather than returning every row.
|
|
655
|
-
- `AKAN_SEARCH_ENABLED=0` switches the index off process-wide; unset means on. It never deletes mirror data, and
|
|
656
|
-
re-enabling reconciles every ref. **Give every process the same value** — a process cannot drop triggers for models
|
|
657
|
-
it does not mount, so a mixed fleet leaves stale triggers behind.
|
|
658
|
-
- The tokenizer is `AKAN_SEARCH_TOKENIZER` (or `database.search.tokenizer`, which wins), defaulting to
|
|
659
|
-
`unicode61 remove_diacritics 2`. Changing it rebuilds the index from the mirror on the next boot — the model
|
|
660
|
-
tables are never re-read — so it is a safe knob, unlike a `text` role change, which re-reads every row. The
|
|
661
|
-
rebuild takes no cross-process claim, so a fleet restarted at once repeats it in every process; stagger the
|
|
662
|
-
restart when the mirror is large.
|
|
663
755
|
|
|
664
756
|
### Image & File Fields
|
|
665
757
|
|
|
666
758
|
- **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.
|
|
667
|
-
- Declare an image/file field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many
|
|
759
|
+
- Declare an image/file field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many.
|
|
668
760
|
- 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`).
|
|
669
|
-
- Storage is wired through the `StorageAdaptor` DI role (default `BlobStorage`, `pkgs/akanjs/service/predefinedAdaptor/storage.adaptor.ts`); the reference implementation is the `file`
|
|
761
|
+
- 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.
|
|
670
762
|
|
|
671
763
|
### Cascade Remove — the `cascade` option
|
|
672
764
|
|
|
765
|
+
Full contract — bulk planning, the auto-created index, cycle cutting, boot-time validation: `get_guideline` with
|
|
766
|
+
`queryRule`.
|
|
767
|
+
|
|
673
768
|
**The value names the direction, and getting it wrong is a data loss.** The two actions can sit on the same field
|
|
674
769
|
shape, so `cascade` never means "related" — it means one of exactly these:
|
|
675
770
|
|
|
676
771
|
- `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
|
|
677
|
-
`image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it
|
|
678
|
-
`ID`, and a scalar each fail the class build, because none of them names a document to remove.
|
|
772
|
+
`image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it.
|
|
679
773
|
- `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
|
|
680
|
-
its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three
|
|
681
|
-
a relation
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
and
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
the
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
- **Bulk is decided at boot, per target model, for both directions.** When the target has no `remove` schema hook, no
|
|
692
|
-
`_pre`/`_postRemove` (its own or a lib's), no cascade of its own, and no children, one `removeManyByQuery` leaves
|
|
693
|
-
exactly the rows the loop would, so the framework takes it. Adding a `_postRemove` to that model silently flips it
|
|
694
|
-
back to one document at a time — the boot log (`info` summary, `verbose` per edge) is the only place that shows.
|
|
695
|
-
- **The plan is sealed after every service is live**, so a `listenPost("remove")` registered in `onInit` still counts
|
|
696
|
-
and a `removeRef` target the app never mounted fails the boot rather than the first removal. An unmounted
|
|
697
|
-
`removeWith` owner fails the boot too; an unmounted `refPath` candidate only warns, since that list spans optional
|
|
698
|
-
modules by design.
|
|
774
|
+
its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three
|
|
775
|
+
forms: a relation, an id with `ref`, or a polymorphic id with `refPath`. An array, a Map, `ref` together with
|
|
776
|
+
`refPath`, and a field naming no owner each fail the class build.
|
|
777
|
+
- **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time. The one
|
|
778
|
+
exception is opt-in and priced: `cascade: "removeWithAny"` takes a free-form `String` type field holding the
|
|
779
|
+
owner's refName, and sweeps for children on **every** model's removal. The sweep is one indexed probe, but a
|
|
780
|
+
single wildcard edge turns every cascade in the app back to one document at a time; the boot log names the
|
|
781
|
+
edges in one `info` line. The action carries its own `refPath` in the option type, so the widening cannot be
|
|
782
|
+
declared apart from the direction it widens.
|
|
783
|
+
- **A cascade goes through the target's service, never its model** — that path is what runs the target's
|
|
784
|
+
`_postRemove`, where a module puts the side effect the removal has to carry.
|
|
699
785
|
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
|
|
700
|
-
`origin`, so two parents can share one row;
|
|
701
|
-
|
|
702
|
-
- Removal is soft (`removedAt`) but the storage delete a `_postRemove` performs is not — a cascade is not
|
|
786
|
+
`origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
|
|
787
|
+
- Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
|
|
703
788
|
restorable, and reviving the owner does not revive what went with it.
|
|
704
|
-
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
- **Query-level removes fire no hooks and therefore no cascade.** `removeManyByQuery` / `updateManyByQuery`, the
|
|
708
|
-
generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById` stamp
|
|
709
|
-
`removedAt` in one atomic UPDATE, so nothing downstream runs. Remove one document at a time when it cascades.
|
|
710
|
-
- Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write, so a retry after a partial
|
|
711
|
-
failure re-stamps nothing. Cycles are cut by a visited set carried down the whole chain, with a depth cap of 16.
|
|
789
|
+
- **Query-level removes fire no hooks and therefore no cascade** — `removeManyByQuery` / `updateManyByQuery`, the
|
|
790
|
+
generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
|
|
791
|
+
at a time when it cascades.
|
|
712
792
|
|
|
713
793
|
## Akan Page Routing (`apps/**/page/**`)
|
|
714
794
|
|
|
715
795
|
- `apps/<app>/page` may contain route modules only. Do not add helper logic or component-only files there.
|
|
716
796
|
- Route source files under `page/` must use `.tsx`. Do not add `logic.ts`, `.js`, or `.jsx` files under `page/`.
|
|
717
|
-
-
|
|
797
|
+
- A route page is either `<routeName>.tsx` (serving `/routeName`) or a directory's `_index.tsx`; layouts use `_layout.tsx`; per-route UI overrides use `_overrides.tsx`.
|
|
718
798
|
- Reserved `_*.tsx` route filenames are limited to `_index.tsx`, `_layout.tsx`, and `_overrides.tsx`; do not add files like `_Component.tsx` or `_helper.tsx`.
|
|
719
799
|
- Page filenames must not start with an uppercase letter. Move helper components like `Component.tsx` to app `ui`, `common`, or `lib` instead.
|
|
720
800
|
- Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
|
|
721
|
-
-
|
|
801
|
+
- **A route file exports one thing**: `export default page()…render(…)` for a page, `layout()…render(…)` for a
|
|
802
|
+
`_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting
|
|
803
|
+
is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()` / `.metadata()`, `.loading()`;
|
|
804
|
+
`.notFound()` / `.error()` on a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()`
|
|
805
|
+
/ `.layoutStyle()` / `.gaTrackingId()` on the root layout; `.prompt()` on a page. A named export beside the chain,
|
|
806
|
+
or `page()` in a `_layout.tsx`, fails the build.
|
|
807
|
+
- **A page names every `[x]` segment of its path with `.param("x", Type)`** and reads a query key only through
|
|
808
|
+
`.search("k", Type)`. Values arrive typed — `ID`/`String` → string, `Int`/`Float` → number, `Boolean`, `Date` →
|
|
809
|
+
Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
|
|
810
|
+
search value that fails is dropped. A layout may declare a subset. Names are string literals: `akan sync` reads
|
|
811
|
+
them off the source and refuses a `[projectId]` folder whose page declares no `.param("projectId")`. `lang` is
|
|
812
|
+
never declared: every route sits under `/:lang`, and the locale segment reaches every stage as `lang`.
|
|
722
813
|
- `_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.
|
|
723
|
-
-
|
|
814
|
+
- The legacy shape — `export default async function Page` beside `pageConfig` / `head` / `Loading` exports — still
|
|
815
|
+
loads and logs one deprecation warning per file at boot. Migrate with the `workspaceRecipes` recipe "Migrating
|
|
816
|
+
`page/` to the route chain"; new files are never written in it.
|
|
724
817
|
- `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.
|
|
725
818
|
- `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.
|
|
726
|
-
- Both path segments are route groups, so a lib route mounts at its own path (`libs
|
|
727
|
-
-
|
|
728
|
-
- Before changing route behavior, check `pkgs/akanjs/server/
|
|
819
|
+
- 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.
|
|
820
|
+
- `.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.
|
|
821
|
+
- Before changing route behavior, check `pkgs/akanjs/server/routeTreeBuilder.ts` and nearby routes for the expected pattern.
|
|
729
822
|
|
|
730
823
|
### Page Body Shape
|
|
731
824
|
|
|
732
825
|
```tsx
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
return <Task.Zone.Card init={taskInitInOrg} prefix={`/org/${orgId}`} />;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
export const pageConfig = { transition: "stack" } satisfies PageConfig;
|
|
826
|
+
export default page()
|
|
827
|
+
.param("orgId", ID)
|
|
828
|
+
.config({ transition: "stack" })
|
|
829
|
+
.render(async ({ orgId }) => {
|
|
830
|
+
const { l } = usePage();
|
|
831
|
+
getSelf({ unauthorize: "/signin" });
|
|
832
|
+
const [{ org }, { taskInitInOrg }] = await Promise.all([fetch.viewOrg(orgId), fetch.initTaskInOrg(orgId)]);
|
|
833
|
+
return <Task.Zone.Card init={taskInitInOrg} prefix={`/org/${orgId}`} />;
|
|
834
|
+
});
|
|
746
835
|
```
|
|
747
836
|
|
|
748
|
-
- There is no `loader=` / `render=` page prop
|
|
749
|
-
|
|
750
|
-
- Body order
|
|
837
|
+
- There is no `loader=` / `render=` page prop and no `PageProps` interface: the chain declares the arguments and
|
|
838
|
+
`.render()` receives them flat, typed, with `lang` on every route and `children` added for a layout.
|
|
839
|
+
- Body order inside `.render()`: `usePage()`, auth, fetch, return.
|
|
751
840
|
- Run independent fetches through `Promise.all`, even when there is only one.
|
|
752
841
|
- Gate auth at `_layout.tsx`; repeating `getSelf({ unauthorize: "/signin" })` in the page is fine and common.
|
|
753
|
-
-
|
|
842
|
+
- `async` on the render callback only when the body awaits; the callback is not a React component, so it takes no
|
|
843
|
+
name and no return type.
|
|
844
|
+
- Publish a screen to agents with `.prompt(name, description)` — see MCP Exposure.
|
|
754
845
|
- No `useState`, no `useEffect`, and no comments in page files.
|
|
755
846
|
|
|
756
847
|
## Akan Sync Conventions (`apps/**`, `libs/**`)
|
|
757
848
|
|
|
758
|
-
- `apps/<appName>` root may only contain these files: `AGENTS.md`, `CLAUDE.md`, `akan.app.json`, `akan.config.ts`, `capacitor.config.ts`, `client.ts`, `main.ts`, `package.json`, `server.ts`, `tsconfig.json`.
|
|
849
|
+
- `apps/<appName>` root may only contain these files: `AGENTS.md`, `CLAUDE.md`, `akan.app.json`, `akan.config.ts`, `capacitor.config.ts`, `client.ts`, `main.ts`, `package.json`, `server.ts`, `tsconfig.json`, `tsconfig.tsbuildinfo`.
|
|
759
850
|
- `apps/<appName>` root may only contain these folders: `.akan`, `android`, `common`, `env`, `ios`, `lib`, `mobile`, `page`, `plugin`, `private`, `public`, `script`, `secrets`, `srvkit`, `ui`, `webkit`.
|
|
760
|
-
-
|
|
851
|
+
- `libs/<libName>` root may only contain these files: `AGENTS.md`, `CLAUDE.md`, `README.md`, `akan.config.ts`, `akan.lib.json`, `client.ts`, `index.ts`, `package.json`, `server.ts`, `tsconfig.json`, `tsconfig.spec.json`, `tsconfig.tsbuildinfo`.
|
|
852
|
+
- `libs/<libName>` root may only contain these folders: `common`, `env`, `lib`, `page`, `plugin`, `private`, `public`, `srvkit`, `ui`, `webkit`. A library is never booted or packaged as an app, so the run and mobile entries an app carries (`main.ts`, `capacitor.config.ts`, `.akan`, `android`, `ios`, `mobile`, `script`, `secrets`) are rejected there.
|
|
853
|
+
- Both allowlists have one source — `pkgs/@akanjs/devkit/workspaceLayout.ts`. `akan sync` (error), `akan doctor`
|
|
761
854
|
(diagnostic), and `akan quality scan` (warning) all read it, so add a new root entry there and mirror it into this
|
|
762
855
|
list, never into one of the three call sites.
|
|
763
856
|
- `akan sync` maintains a scoped agent guide per app/lib: `apps/<app>/AGENTS.md` / `libs/<lib>/AGENTS.md`. The
|
|
764
857
|
section between the `akan:agent` markers (the `## Recipes In Scope` index) is generated — do not hand-edit it;
|
|
765
858
|
content outside the markers is yours. `akan lint` fails when the generated section is stale.
|
|
766
859
|
- The `plugin/` facet holds Akan plugin declarations; files use the `<name>.plugin.ts` convention (e.g. `pushNotification.plugin.ts`) and are re-exported from the generated `plugin/index.ts` barrel.
|
|
767
|
-
- Do not add `apps/*/base`; place shared
|
|
768
|
-
- `apps/*/lib` and `libs/*/lib` root files are limited to generated/support files: `cnst.ts`, `db.ts`, `dict.ts`, `option.ts`, `sig.ts`, `srv.ts`, `st.ts`, `useClient.ts`, `useServer.ts`.
|
|
860
|
+
- Do not add `apps/*/base` or `libs/*/base`; place shared utilities under that app or lib's own `common/`.
|
|
861
|
+
- `apps/*/lib` and `libs/*/lib` root files are limited to generated/support files: `cnst.ts`, `db.ts`, `dict.ts`, `option.ts`, `sig.ts`, `srv.ts`, `st.ts`, `useClient.ts`, `useServer.ts` — plus a `<model>.signal.test.ts` / `.spec.ts`, the one hand-written file that belongs there because the suite boots the whole barrel.
|
|
769
862
|
- Domain module folders are `lib/<model>` for database modules, `lib/_<service>` for service modules, and `lib/__scalar/<scalar>` for scalar modules.
|
|
770
863
|
- Database module UI files are limited to `<Model>.Template.tsx`, `<Model>.Unit.tsx`, `<Model>.Util.tsx`, `<Model>.View.tsx`, and `<Model>.Zone.tsx`.
|
|
771
864
|
- Service module UI files are limited to `<Service>.Util.tsx` and `<Service>.Zone.tsx`.
|
|
@@ -802,7 +895,7 @@ when two shapes disagree.
|
|
|
802
895
|
- Stale `// TODO: Implement …` comments above implemented methods.
|
|
803
896
|
- `{cond && <X/>}` in JSX, hard-coded Korean bypassing `l()`, and `window.alert(...)` for user feedback.
|
|
804
897
|
- Bare `/* eslint-disable */` blocks — use `// biome-ignore lint/<rule>: <why>`.
|
|
805
|
-
- Raw palette grays such as `text-gray-400` instead of
|
|
898
|
+
- Raw palette grays such as `text-gray-400` instead of the semantic tokens (`text-foreground/70`).
|
|
806
899
|
|
|
807
900
|
## Secrets And Env Safety (`.env`, `infra/**`, `*secret*`, `*credential*`)
|
|
808
901
|
|
|
@@ -815,11 +908,42 @@ when two shapes disagree.
|
|
|
815
908
|
## Application Test Commands
|
|
816
909
|
|
|
817
910
|
- After changing application source code, test the app with `bun run akan start <appName>`.
|
|
911
|
+
- **`akan start` takes several apps** — `akan start a,b`, `akan start a b`, `akan start all`, or no argument at
|
|
912
|
+
all for a checkbox that remembers the last pick.
|
|
913
|
+
- **It opens a full-screen view by default, at any app count.** A rail of apps with the processes inside each
|
|
914
|
+
one — `host` for the dev host, gateway and RSC worker, plus a row per replica the gateway forwards — and the
|
|
915
|
+
selected one's log beside it. `Tab` walks every row and `1`-`9` jump to that app (the rail shows each
|
|
916
|
+
app's digit), `↑↓` scroll (shift for a page, `G` back to following),
|
|
917
|
+
`/` greps, `e` shows stderr only, `c` clears, `y` copies, `Y` copies the log path, `o` opens the browser,
|
|
918
|
+
`r` restarts that app, `q` quits.
|
|
919
|
+
**`--plain` prints prefixed interleaved lines instead**, and a pipe, a redirect or an unsized terminal
|
|
920
|
+
downgrades to that on its own.
|
|
921
|
+
- **A supervised session writes `local/apps/<app>/runtime/dev.log`, and that path is how a log gets handed over.**
|
|
922
|
+
Ink repaints one frame in place, so the full-screen view leaves a bordered screenshot in the scrollback and
|
|
923
|
+
takes the rest of the session with it — a drag-selection is cleared by the next repaint, and what survives
|
|
924
|
+
carries borders and lines truncated to the pane. The file is the same output with no ANSI, nothing truncated,
|
|
925
|
+
and every process of the app in arrival order, including the dev host's own build output, which reaches no
|
|
926
|
+
other file. The previous session is kept beside it as `dev.prev.log`. Hand somebody — or an agent — the
|
|
927
|
+
path rather than a paste: it costs one line instead of a transcript, and it can be re-read after a fix.
|
|
928
|
+
In the view, `y` copies the lines the filters have already narrowed and `Y` copies the path.
|
|
929
|
+
- **How many apps boot at once is the machine's answer, not a constant.** A cold boot build is the builder's
|
|
930
|
+
RSS peak (~900MB per app), so the default wave is what memory and cores allow — half the memory budget
|
|
931
|
+
divided by that peak, and one app per four cores — which boots a laptop's apps together and still staggers
|
|
932
|
+
them inside a small container. The session says which in one line. `--concurrency <n>` overrides it, and
|
|
933
|
+
`AKAN_MEMORY_LIMIT` lowers the budget it derives from.
|
|
934
|
+
- `--kill` frees the dev ports first — it resolves each port's listener, walks up to the top of that akan dev
|
|
935
|
+
tree and signals it, so another checkout's server or a stale orphan on the same port is reclaimed. A holder
|
|
936
|
+
that is not recognisably an akan process is reported and left alone.
|
|
937
|
+
- **A dev server watches its own app directory and the libs it actually depends on**, transitively, resolved
|
|
938
|
+
once at boot from the synced manifests — so a save under a lib the app never imports rebuilds and reloads
|
|
939
|
+
nothing, and neither does a save in a sibling app. An import that makes a new lib a dependency needs a
|
|
940
|
+
fresh `akan start`, because it needs a `sync` first anyway. Before the first `sync` the manifests do not
|
|
941
|
+
exist yet and the whole `libs/` container is watched, which is the safe direction.
|
|
818
942
|
- Test production build generation with `bun run akan build <appName>`.
|
|
819
943
|
- To test a built artifact locally, run it from the generated app directory with the required Akan runtime environment variables.
|
|
820
944
|
|
|
821
945
|
```bash
|
|
822
|
-
cd dist/apps
|
|
946
|
+
cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=<repo> AKAN_PUBLIC_SERVE_DOMAIN="<domain>" \
|
|
947
|
+
AKAN_PUBLIC_APP_NAME=<appName> AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation \
|
|
948
|
+
AKAN_PUBLIC_BASE_PATHS=<basePaths> bun main.js
|
|
823
949
|
```
|
|
824
|
-
|
|
825
|
-
- Adjust `<appName>`, `AKAN_PUBLIC_APP_NAME`, and `AKAN_PUBLIC_BASE_PATHS` to match the app being tested.
|