@akanjs/cli 3.0.0-alpha.70 → 3.0.0-alpha.71
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/{agent.command-964bf8qg.js → agent.command-xdvw642m.js} +5 -5
- package/{application.command-n45n18sc.js → application.command-g3f7xj25.js} +27 -4
- package/buildBatch.proc.js +1 -1
- package/{capacitorApp-vnvnvdhf.js → capacitorApp-b0ppke3m.js} +2 -2
- package/{cloud.command-kx1bh5yq.js → cloud.command-0hmsv44q.js} +7 -7
- package/commandManifest.json +1 -1
- package/{context.command-g1a65x6j.js → context.command-x92afrnf.js} +12 -12
- package/{guideline.command-xphyhvwh.js → guideline.command-hpq08q66.js} +2 -2
- package/guidelines/conventions/conventions.instruction.md +255 -436
- package/guidelines/framework/framework.generate.json +3 -2
- package/guidelines/framework/framework.instruction.md +0 -9
- package/guidelines/mcpRule/mcpRule.instruction.md +5 -8
- package/guidelines/queryRule/queryRule.generate.json +31 -0
- package/guidelines/queryRule/queryRule.instruction.md +143 -0
- package/guidelines/runtimeRule/runtimeRule.generate.json +25 -0
- package/guidelines/runtimeRule/runtimeRule.instruction.md +176 -0
- package/guidelines/transportRule/transportRule.generate.json +31 -0
- package/guidelines/transportRule/transportRule.instruction.md +60 -0
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +17 -392
- package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +33 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +402 -0
- package/incrementalBuilder.proc.js +1 -1
- package/{index-nkwmnqwn.js → index-5f4gnt4c.js} +1 -0
- package/{index-va15r8h5.js → index-6d5r8q6k.js} +2 -2
- package/{index-4rw4qhe6.js → index-737aj4fm.js} +3 -3
- package/{index-ytaqtc04.js → index-8besc14m.js} +1 -1
- package/{index-hee7egtm.js → index-cbfp5wyp.js} +1 -1
- package/{index-mkn5qagt.js → index-cp00yns2.js} +70 -4
- package/{index-nj3w0r34.js → index-ety6takb.js} +3 -3
- package/{index-e31ghfp5.js → index-gvq17wqn.js} +1 -1
- package/{index-8gybxemv.js → index-hs70d8ed.js} +5 -5
- package/{index-0ngvvgry.js → index-jybbmwed.js} +2 -2
- package/{index-bqqts262.js → index-m5gfc86a.js} +1 -1
- package/{index-qt4nfr0n.js → index-nq3avsf6.js} +10 -10
- package/{index-yg3hxk2k.js → index-p27h7535.js} +4 -4
- package/{index-fahkq20v.js → index-pehn44hr.js} +3 -3
- package/{index-v5fdxdre.js → index-r691v756.js} +2 -2
- package/{index-p681w089.js → index-sqsfv8wg.js} +2 -2
- package/{index-rbnpe8cp.js → index-wbbswwqt.js} +17 -21
- package/{index-rb65xnw9.js → index-wybbcgxn.js} +4 -4
- package/index.js +18 -18
- package/{library.command-rfpd27yv.js → library.command-8vgrdtwy.js} +3 -3
- package/{localRegistry.command-pyxfe0fs.js → localRegistry.command-0gtqj0r0.js} +6 -6
- package/{module.command-qhaxad0p.js → module.command-rama4zya.js} +5 -5
- package/{package.command-2k22q9nt.js → package.command-6jxapdsg.js} +3 -3
- package/package.json +2 -2
- package/{page.command-ymk9dqh4.js → page.command-hxy7x525.js} +3 -3
- package/{primitive.command-9k45zpc1.js → primitive.command-4bf6xcc3.js} +6 -6
- package/{quality.command-bf6vkxe5.js → quality.command-h5edwegz.js} +2 -2
- package/{repair.command-egvvm6xf.js → repair.command-rgqf6px6.js} +5 -5
- package/{scalar.command-bt1facyj.js → scalar.command-6krkz6tw.js} +4 -4
- package/templates/workspaceRoot/AGENTS.md.template +4 -0
- package/{workflow.command-v1n3wg7v.js → workflow.command-dwhwrqhd.js} +9 -9
- package/{workspace.command-yvvqvhzz.js → workspace.command-f5h11gjn.js} +18 -18
|
@@ -19,6 +19,25 @@
|
|
|
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
43
|
Enforced by `biome.json`, which extends `@akanjs/devkit/biome.base.json` — that file scopes every grit plugin in
|
|
@@ -26,56 +45,54 @@ Enforced by `biome.json`, which extends `@akanjs/devkit/biome.base.json` — tha
|
|
|
26
45
|
back.
|
|
27
46
|
|
|
28
47
|
- **Never hand-order Tailwind classes.** `nursery/useSortedClasses` is an error and also sorts the string
|
|
29
|
-
arguments to `cn()`. Sorter output such as `font-bold text-2xl text-foreground` or
|
|
30
|
-
|
|
31
|
-
- **Stay inside the color vocabulary.** Vocabulary closure strips the raw Tailwind palette, so these render as
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
(`bg-
|
|
35
|
-
|
|
36
|
-
`
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
spelled out.
|
|
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
|
|
55
|
+
(`bg-primary`, `text-foreground/70`). A legitimate fixed color (OS-chrome mockups, data-viz) takes a
|
|
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`)
|
|
40
58
|
- **Never `throw new Error`.** Throw `new Err("<module>.error.<key>")` and register the key as `[en, ko]` in that
|
|
41
59
|
module's dictionary `.error({})`. Import `Err` from `"../dict"` on the server and from `"@libs/<lib>/client"` or
|
|
42
|
-
`"@apps/<app>/client"` in UI. `no-throw-raw-error.grit` exempts tests, `*.constant.ts`, `common/**`, and
|
|
43
|
-
|
|
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.
|
|
44
62
|
- **Never import a third-party package** from `page/**`, from any barrel, or from any
|
|
45
63
|
`*.{constant,dictionary,document,service,signal,store}.ts` / `*.{Template,Unit,Util,View,Zone}.tsx`
|
|
46
64
|
(`no-import-external-library.grit`). Re-export the symbol through a lib first. One-line re-export shims in a lib's
|
|
47
|
-
`common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft.
|
|
48
|
-
them.
|
|
65
|
+
`common/`, `webkit/`, or `ui/` exist for exactly this reason — they are load-bearing, not cruft.
|
|
49
66
|
- **`#private` is banned in exactly four file suffixes:** `*.constant.ts`, `*.document.ts`, `*.service.ts`, and
|
|
50
67
|
`*.store.ts` (`no-js-private-class-method.grit`). The rule is scoped by file path, not by class shape, so
|
|
51
68
|
`#private` remains the house style everywhere under `srvkit/`, including `adapt()` adapter classes.
|
|
52
69
|
- **No `console.log` / `console.debug`.** Only `assert`, `error`, `info`, and `warn` are allowed. Server code uses
|
|
53
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()`.
|
|
54
75
|
- **Never write a `//!` marker in browser-reachable code** — `ui/`, `webkit/`, `common/`, `page/**/*.tsx`,
|
|
55
76
|
`*.constant.ts`, `*.store.ts`, and the five module component suffixes (`no-bang-comment-in-client.grit`). Bun
|
|
56
77
|
classifies `//!` and `/*!` as legal comments and keeps them through minification, so the note ships to every
|
|
57
78
|
visitor. Use `// FIXME:` there; `//!` stays legal in server, `srvkit/`, and CLI files.
|
|
58
79
|
- **Never return a value from a store action** (`no-return-in-store-action.grit`). Every method of a `store(...)`
|
|
59
|
-
class dispatches through `st.do.<action>()`,
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
62
83
|
- **Never redeclare a generated CRUD endpoint name** in `*.signal.ts` (`no-redeclare-predefined-endpoint.grit`).
|
|
63
84
|
- **Never type a `*.Util.tsx` / `*.Zone.tsx` prop as a `cnst` model** (`no-model-type-in-util-zone.grit`). Those two
|
|
64
|
-
roles are always client components, so a `cnst.Banner` / `cnst.LightBanner` prop is a class instance the server
|
|
65
|
-
to hand across the boundary; take `bannerId: string` and read the model from the store instead.
|
|
66
|
-
positions are read
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
`
|
|
70
|
-
never leaves the file. `ModelProps<"setting", cnst.LightSetting>` and any other indexed access
|
|
71
|
-
(`cnst.Banner["image"]` is a `File`) stay flagged.
|
|
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.
|
|
72
91
|
- **Never wrap a form setter in a pass-through arrow** (`no-unpublished-form-setter.grit`).
|
|
73
92
|
`onChange={(type) => st.do.setTypeOnTicket(type)}` runs identically to `onChange={st.do.setTypeOnTicket}`, but
|
|
74
93
|
the arrow is a fresh anonymous closure, so the control emits no `data-akan-action` and publishes no agent tool
|
|
75
94
|
for the field — a silent failure in two lines that read the same. A wrapper that transforms the value, adds a
|
|
76
95
|
statement, or writes a nested path with `writeOnX` stays legal; publish that one with an explicit `st.tool`.
|
|
77
|
-
Scoped to `{apps,libs}/**/*.tsx`, and a typed parameter is not matched, so it under-reports rather than
|
|
78
|
-
misfiring.
|
|
79
96
|
- **No deep imports past a barrel** (`no-deep-internal-import.grit`). Cross-module constant references such as
|
|
80
97
|
`../map/map.constant` are the sanctioned exception.
|
|
81
98
|
- **Never import across the client/server boundary.** Client files (`ui/`, `webkit/`, `page/`, `*.store.ts`, every
|
|
@@ -85,41 +102,33 @@ back.
|
|
|
85
102
|
`*.store.ts`, a module component, `ui/`, `webkit/`, a package `client` entrypoint, or the `st` / `store` /
|
|
86
103
|
`useClient` barrels (`no-import-client-in-server.grit`). Shared files — `common/` and `*.constant.ts` — are held
|
|
87
104
|
to **both**, so they reach neither side. `import type` is erased before bundling and stays legal in every
|
|
88
|
-
direction; a mixed value-and-type import is not exempt. Scoped to `apps/**` and `libs
|
|
89
|
-
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/**`.
|
|
90
106
|
- **Server-component discipline** is enforced on `page/**`, `*.Unit.tsx`, and `*.View.tsx`
|
|
91
107
|
(`no-import-client-functions.grit`, `no-use-client-in-server.grit`, `non-scalar-props-restricted.grit`).
|
|
92
108
|
- **Never write an async component outside `page/`** (`no-async-component-in-ui.grit`, scoped to
|
|
93
109
|
`{apps,libs}/**/ui/**/*.tsx`). React has no async client component, so a `ui/` component that awaits breaks as
|
|
94
110
|
soon as a client parent renders it, and the load drops below the route, which could have started it before the
|
|
95
111
|
first byte. Await in the page — or hand an unawaited `fetch.*` to a `Zone` as an `init` / `view` prop — and take
|
|
96
|
-
the resolved data as a prop.
|
|
97
|
-
handler
|
|
98
|
-
`lazy(async () => import(…))` are all untouched.
|
|
112
|
+
the resolved data as a prop. Only a PascalCase binding whose own initializer is `async` is matched, so an async
|
|
113
|
+
handler inside a synchronous component and `lazy(async () => import(…))` are untouched.
|
|
99
114
|
- **Never call `fetch.init*` from a client file** (`no-init-fetch-in-client.grit`). `fetch.init<Model><Suffix>` and
|
|
100
115
|
`fetch.get<Model>Init<Suffix>` compose the slice's list and insight queries into the hydration snapshot that
|
|
101
|
-
`Load.Units` / `Load.View` seed the store from. From a route it resolves before the first byte; after hydration
|
|
102
|
-
is two extra round-trips for a shell the browser already painted
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
carries no directive. The name is matched by shape, since a lint rule cannot know which slices exist: the
|
|
107
|
-
generated one always carries two capital-led segments (`init` + `Capitalize<refName>` + `Capitalize<suffix>`), so
|
|
108
|
-
a hand-written `initPayment` is out and `initializeSomething` was never in. `view` / `edit` hydrate alike but are
|
|
109
|
-
left unmatched — `edit<X>` is a plausible custom endpoint name.
|
|
116
|
+
`Load.Units` / `Load.View` seed the store from. From a route it resolves before the first byte; after hydration
|
|
117
|
+
it is two extra round-trips for a shell the browser already painted. Load it in the page and pass the result
|
|
118
|
+
down as an `init` prop — or hand the unawaited promise across — and reload from the client through the generated
|
|
119
|
+
`st.do.init<Model><Suffix>()`. Gated on the real `"use client"` directive plus `*.store.ts`, and matched by shape
|
|
120
|
+
(`init` + `Capitalize<refName>` + `Capitalize<suffix>`), so a hand-written `initPayment` is out.
|
|
110
121
|
- `noArrayIndexKey` and `useExhaustiveDependencies` are **off** on purpose: `key={idx}` for embedded scalars and
|
|
111
122
|
short dependency arrays are intentional, not oversights.
|
|
112
123
|
- **A grit plugin diagnostic is suppressed as `lint/plugin`, not `plugin`** — `// biome-ignore lint/plugin: <reason>`
|
|
113
|
-
for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form
|
|
114
|
-
own category name suggests does nothing.
|
|
115
|
-
|
|
116
|
-
ones) apply to `apps/**` and `libs/**` only, so a plain package under `pkgs/` never needs the escape hatch.
|
|
124
|
+
for one line, `// biome-ignore-all lint/plugin: <reason>` for a file. The bare `// biome-ignore plugin:` form
|
|
125
|
+
Biome's own category name suggests does nothing. The module-convention plugins apply to `apps/**` and `libs/**`
|
|
126
|
+
only, so a plain package under `pkgs/` never needs the escape hatch.
|
|
117
127
|
- **`biome.json` is strict JSON — a comment in it breaks config resolution.** Biome does not report the parse
|
|
118
128
|
error; it falls back to discovery and aborts on whatever nested config the walk finds. Rename the file to
|
|
119
|
-
`biome.jsonc` to document a disabled rule
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
with no count, which reads as progress when the mix of findings merely changed.
|
|
129
|
+
`biome.jsonc` to document a disabled rule.
|
|
130
|
+
- **`akan lint` prints up to 200 diagnostics** (`--max-diagnostics <n>`, `0` for no limit). Biome's own default is
|
|
131
|
+
20 with no count, which reads as progress when the mix of findings merely changed.
|
|
123
132
|
|
|
124
133
|
## Coding Style (`**/*.{ts,tsx}`)
|
|
125
134
|
|
|
@@ -247,158 +256,69 @@ are, because those are the ones the server could have done.
|
|
|
247
256
|
1. **Wrap the interaction, not the UI.** The smallest useful client component adds one behaviour and renders
|
|
248
257
|
`children` untouched, so the markup inside never reaches the bundle.
|
|
249
258
|
2. **Split compound components so panels stay on the server.** `Tab` / `Tab.Menus` / `Tab.Menu` / `Tab.Panel` in
|
|
250
|
-
`akanjs/ui` is the shape: only the provider and menu hold state
|
|
251
|
-
|
|
252
|
-
3. **Sync state instead of fetching it.** The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and passes
|
|
253
|
-
|
|
254
|
-
|
|
259
|
+
`akanjs/ui` is the shape: only the provider and menu hold state. Never one `"use client"` file with a mode
|
|
260
|
+
`useState` and every panel body inlined.
|
|
261
|
+
3. **Sync state instead of fetching it.** The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and passes the
|
|
262
|
+
result into a `Zone` as an `init` / `view` prop; `Load.Units` / `Load.View` hydrate the store from it. Never a
|
|
263
|
+
`useEffect(…, [])` that fetches on mount.
|
|
255
264
|
4. **Push the boundary down to the leaf that needs it.** A store-reading `Zone` should hold zero markup and
|
|
256
265
|
delegate to a server `View`.
|
|
257
266
|
5. **Hand the promise across, not the awaited value.** `ClientInit` / `ClientView` are `PromiseOrObject<T>`, so a
|
|
258
267
|
route may pass an unawaited `fetch.initX(...)` and `Load.*` resolves it behind a skeleton.
|
|
259
|
-
6. **Use named `ReactNode` slots, not just `children
|
|
268
|
+
6. **Use named `ReactNode` slots, not just `children`** — `Layout.Navbar` takes `title`, `back`, `left`, `right`,
|
|
260
269
|
and `children`, so a client shell composes server content in five places instead of absorbing it.
|
|
261
270
|
7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
|
|
262
271
|
lookups belong in a module-scope `as const` map.
|
|
263
|
-
8. **Gate auth on the server
|
|
264
|
-
is sent.
|
|
272
|
+
8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any HTML is sent.
|
|
265
273
|
9. **Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants, or
|
|
266
274
|
`<details>`/`<summary>`, keeps both branches server-rendered.
|
|
267
|
-
10. **Keep the heavy island out of the first load
|
|
268
|
-
`
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
`
|
|
288
|
-
|
|
289
|
-
- **`
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
`
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
`
|
|
306
|
-
|
|
307
|
-
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
A container runs one process per replica, a gateway in front of them when there is more than one, and one RSC
|
|
315
|
-
worker per web-serving replica.
|
|
316
|
-
|
|
317
|
-
- **One traffic replica runs in the container's only process.** `AKAN_REPLICA=0,0,1` — the default, and what
|
|
318
|
-
every environment in `infra/app/values` sets — means there is nothing to balance and nothing to fan pubsub out
|
|
319
|
-
to, so `AkanApp` starts that replica in-process rather than spawning it. Measured on `apps/akan`: 28MB less RSS
|
|
320
|
-
and twice the requests per second, because every request used to cross a unix-socket proxy hop. Declare two or
|
|
321
|
-
more and the gateway is back, spawning and proxying them.
|
|
322
|
-
- **`AKAN_SOLO=false` forces the gateway** for a single replica. Like `AKAN_SSR`, the env only narrows — it
|
|
323
|
-
cannot fold a real gateway's replicas into one process. Passing `replica` to `new AkanApp(...)` also keeps the
|
|
324
|
-
gateway: code that states a topology is asking for the thing that serves it.
|
|
325
|
-
- **`akan start` always runs the gateway**, whatever the replica count. It is also the dev host's builder relay,
|
|
326
|
-
its crash page, and what holds the port across a child restart.
|
|
327
|
-
- **A batch-only replica (`0,1,0`) keeps the gateway too**, because a batch server never listens and the gateway
|
|
328
|
-
is then the only thing bound to answer `/_akan/app/health`.
|
|
329
|
-
- **The RSC worker is never folded in.** It runs under `--conditions react-server`, which resolves the same
|
|
330
|
-
module graph differently, so it cannot share a process with the server that renders client components.
|
|
331
|
-
- **A solo process answers `/_akan/app/health`, `/_akan/app/metrics` and `/_akan/bench/ping` itself**, in the
|
|
332
|
-
gateway's own shape — a `children` array with one entry — so a probe reads one contract either way. It owns the
|
|
333
|
-
rotating log file the gateway would otherwise write, in the same `runtime/logs` directory.
|
|
334
|
-
- **Nothing supervises a solo process but the orchestrator**, since the gateway's crash-restart-with-backoff went
|
|
335
|
-
with it. `infra/app/templates/app.yaml` carries the liveness, readiness and startup probes that replace it.
|
|
336
|
-
- **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel.** The barrel re-exports
|
|
337
|
-
`AkanServer`, whose graph the gateway never runs; through it the process evaluated 35MB of SSR renderer and
|
|
338
|
-
SQLite driver to spawn children and relay bytes. Keep entrypoint imports at the leaf.
|
|
339
|
-
|
|
340
|
-
## The Generated Image — `docker` In `akan.config.ts`
|
|
341
|
-
|
|
342
|
-
**`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
|
|
343
|
-
assembles one from. There is no `content` field; the string *is* the content.
|
|
344
|
-
|
|
345
|
-
```ts
|
|
346
|
-
const config: AppConfig = {
|
|
347
|
-
docker: { preRuns: ["apt-get update && apt-get install -y --no-install-recommends ffmpeg"] },
|
|
348
|
-
};
|
|
349
|
-
const config: AppConfig = { docker: "FROM oven/bun:1-slim\n…" }; // verbatim, nothing merged in
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
- `image` and each run entry take `string | { amd64?, arm64? }`; the object form compiles to a
|
|
353
|
-
`RUN if [ "$TARGETARCH" = "<arch>" ]` guard, so a multi-arch build runs it on one leg only. `preRuns` land
|
|
354
|
-
before `bun install --production` (where a native dependency's build tools have to be), `postRuns` after it
|
|
355
|
-
and before the app files are copied.
|
|
356
|
-
- **A lib declares the steps its own runtime needs**, and every app that mounts it inherits them:
|
|
357
|
-
`libs/<lib>/akan.config.ts` takes `docker: { preRuns, postRuns }` and nothing else — the base image and the
|
|
358
|
-
command belong to the app. Lib steps are emitted before the app's own, and an identical step declared twice
|
|
359
|
-
becomes one layer.
|
|
360
|
-
- **The string form takes no contributions.** A Dockerfile handed over whole is used exactly as written, so a
|
|
361
|
-
lib's `preRuns` are dropped rather than spliced into a file Akan does not own. An app that needs both writes
|
|
362
|
-
the parts instead.
|
|
363
|
-
- Like `externalLibs`, lib steps are collected from **every lib in the workspace**, not just this app's
|
|
364
|
-
dependency closure — narrowing that set needs the dependency scan, and this config is re-read on every file
|
|
365
|
-
change in dev. Keep a lib's steps to what its runtime genuinely requires.
|
|
366
|
-
- `AkanAppConfig.docker` is the resolved declaration; `AkanAppConfig.dockerfile` is the text `akan build` writes
|
|
367
|
-
to `dist/apps/<app>/Dockerfile`.
|
|
368
|
-
|
|
369
|
-
## Shipped Assets — `assets` In `akan.config.ts`
|
|
370
|
-
|
|
371
|
-
`akan build` copies the whole `public/` tree into `dist`, lib assets dereferenced, and that copy is the image.
|
|
372
|
-
**`assets: { pruneFonts, keepFonts }`** trims the fonts out of it that nothing reads. **Source trees are never
|
|
373
|
-
touched** — an app's and a lib's own `public/` keep every file, because one shared font folder is picked over
|
|
374
|
-
differently by every app that mounts it and by other repos.
|
|
375
|
-
|
|
376
|
-
```ts
|
|
377
|
-
const config: LibConfig = { assets: { keepFonts: ["fonts/Assistant-*.woff2"] } }; // libs/<lib>/akan.config.ts
|
|
378
|
-
const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve, not the normal escape hatch
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
- **A font with `optimize` on is a build input, not a runtime asset.** `FontOptimizer` subsets it into
|
|
382
|
-
`.akan/artifact/fonts` and the emitted CSS points at `/_akan/fonts/*`, which `WebRouter` serves from the
|
|
383
|
-
artifact — so the source in `public/` is never opened again. That is the bulk of what this drops, and it
|
|
384
|
-
applies to the fonts the app *does* use, not only the ones it does not.
|
|
385
|
-
- **The keep-set is derived per app, per build; there is no list of fonts to drop.** An exclude list would rot
|
|
386
|
-
the moment another app or repo started using one of them, and nobody would know to update it. A font survives
|
|
387
|
-
because ① its font declares `optimize: false`, so `FontCss.getRuntimeCss` emits the raw src; ② something
|
|
388
|
-
references its filename — anything under `public/`, the compiled stylesheet, a client/server bundle, the CSR
|
|
389
|
-
shell; or ③ a `keepFonts` glob names it.
|
|
390
|
-
- **`keepFonts` belongs to the `akan.config.ts` that owns the font**, written against that lib's or app's own
|
|
391
|
-
`public/` (`"fonts/X.woff2"`, resolved to `libs/<lib>/fonts/X.woff2`). A lib shared across repos carries the
|
|
392
|
-
reason its own CSS needs a font, instead of every mounting app rediscovering it in production.
|
|
393
|
-
- **A reference from a build bundle is ignored for an already-subset font.** Every route file's `fonts`
|
|
394
|
-
declaration is inlined into the pages bundle, the client chunks and the CSR shell, so a declared source is in
|
|
395
|
-
all three whether or not anything loads it. `public/` and the compiled CSS are read as authoritative.
|
|
396
|
-
- **A generated manifest that lists every public asset keeps every font it names** — a service-worker precache
|
|
397
|
-
file is the usual one. That is a real reference, not a false positive, so the build reports it at `info`:
|
|
398
|
-
read the `[font-prune] kept …` line before concluding the prune does nothing.
|
|
399
|
-
- Matching is by filename, including the percent-encoded spelling, so a `url()` survives however it is written.
|
|
400
|
-
- The phase is `assets`, between `csr` and `compress`. An api-only build (`web: false`) copies no `public/` and
|
|
401
|
-
the phase finds nothing to do.
|
|
275
|
+
10. **Keep the heavy island out of the first load** — behind the `ui/<Folder>/index_.tsx` + `lazy()` pair.
|
|
276
|
+
`usePage()` and `l()` work in server components, so translation never forces a boundary.
|
|
277
|
+
|
|
278
|
+
Full version with code, the `Tab` composition example, and a review checklist: `get_guideline` with `ssrRule`.
|
|
279
|
+
|
|
280
|
+
## Runtime & Build Configuration
|
|
281
|
+
|
|
282
|
+
What an app serves, how many processes it runs, where its logs go, and what ships in its image. All of it is
|
|
283
|
+
declared in `akan.config.ts` and narrowed — never widened — by an env at boot. Domain code reaches none of it.
|
|
284
|
+
Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runtimeRule`.
|
|
285
|
+
|
|
286
|
+
- **Web surfaces are `web: true | false | { csr: boolean }`.** `{ csr: false }` drops the mobile/CSR bundle,
|
|
287
|
+
`false` is an API-only build that serves nothing under `page/`. There is no CSR-without-SSR option, by type —
|
|
288
|
+
the CSR bundle inlines the stylesheet the SSR base artifact compiles. `AKAN_SSR` / `AKAN_CSR` narrow further at
|
|
289
|
+
runtime and can never switch a surface the build left out back on. `akan start` ignores `web` and keeps the
|
|
290
|
+
whole dev surface.
|
|
291
|
+
- **One traffic replica runs in the container's only process.** `AKAN_REPLICA=0,0,1` — the default everywhere —
|
|
292
|
+
has nothing to balance, so there is no gateway and no unix-socket proxy hop. Two or more replicas, a batch-only
|
|
293
|
+
replica, `AKAN_SOLO=false`, passing `replica` to `new AkanApp(...)`, or `akan start` all bring the gateway back.
|
|
294
|
+
The RSC worker is always its own process (`--conditions react-server`). A solo process answers
|
|
295
|
+
`/_akan/app/health`, `/_akan/app/metrics` and `/_akan/bench/ping` in the gateway's own shape, and owns the
|
|
296
|
+
rotating log file; nothing supervises it but the orchestrator's probes.
|
|
297
|
+
- **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel** — the barrel re-exports
|
|
298
|
+
`AkanServer`, whose graph the gateway never runs. Keep entrypoint imports at the leaf.
|
|
299
|
+
- **Every `Logger` call builds a `LogRecord`** carrying `traceId`, `endpoint` and `origin` alongside the message,
|
|
300
|
+
so one request's lines share a trace. Request context is on by default, production included
|
|
301
|
+
(`AKAN_LOG_CONTEXT=0` opts out). **Give every sink a floor** — `Logger.addSink(sink, { minLevel })`; a sink with
|
|
302
|
+
none makes every `verbose` call render. **Never log per delivered record in anything that delivers records.**
|
|
303
|
+
Tail a running server with `akan logs <app>` (`--level --grep --endpoint --trace --child --role --origin
|
|
304
|
+
--since`, `--json`) or `.tail` / `.trace <id>` inside `akan console`.
|
|
305
|
+
- **`AKAN_LOG_FORMAT=ndjson` makes the container's stdout one JSON record per line, and the hub owner (gateway or
|
|
306
|
+
solo replica) its only writer** — every other server process turns its console off and forwards from
|
|
307
|
+
`AKAN_LOG_STDOUT_LEVEL` (default: the console level); a child's or the RSC worker's crash stack is wrapped as a
|
|
308
|
+
`raw=true` record instead of breaking the stream. Text stays the default; turn it on in the deployment.
|
|
309
|
+
`AKAN_LOG_STREAM_TOKEN` mounts `/_akan/app/logs` (SSE, `Last-Event-ID` resume, explicit `gap` events); unset,
|
|
310
|
+
the route does not exist. `AKAN_LOG_CANONICAL=1|slow` writes one `ok|error <endpoint> ms= status=` line per call;
|
|
311
|
+
`AKAN_LOG_FLIGHT=1` keeps each call's last 64 sub-level records and promotes them (`flight=true`) only when it
|
|
312
|
+
failed or ran past `AKAN_LOG_FLIGHT_MS`; `x-akan-debug` lowers one request to `trace` — in `local`, or with the
|
|
313
|
+
`AKAN_LOG_DEBUG_HEADER` secret. Structured values go in `LogRecord.attrs` via `Logger.emit(...)`; a key naming a
|
|
314
|
+
secret is `[redacted]` before the record exists.
|
|
315
|
+
- **`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
|
|
316
|
+
assembles one from; the string form takes no contributions. The generated image installs `ca-certificates` and
|
|
317
|
+
`tzdata` and nothing else, so an app needing `ffmpeg` or Chromium declares it in `preRuns` / `postRuns`. A lib
|
|
318
|
+
declares the steps its own runtime needs and every mounting app inherits them.
|
|
319
|
+
- **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
|
|
320
|
+
trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
|
|
321
|
+
the `akan.config.ts` that owns the font, written against that scope's own `public/`.
|
|
402
322
|
|
|
403
323
|
## React Components And Styling (`**/*.tsx`)
|
|
404
324
|
|
|
@@ -440,9 +360,14 @@ const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve,
|
|
|
440
360
|
**`<model>.constant.ts`** — five classes in order with one blank line between them and `enumOf("camelName", [...] as const)`
|
|
441
361
|
classes above: `XInput → XObject → LightX → X → XInsight`. Write `XInsight` even when it is empty. Put display and
|
|
442
362
|
predicate logic on `LightX` (`isNew()`, `canWrite(user?)`, `formatTimes()`) — the Light class is the one both server
|
|
443
|
-
and client hold, so shared logic belongs there instead of in a util module. This is the most commonly missed rule
|
|
444
|
-
the codebase
|
|
445
|
-
obvious a short trailing comment.
|
|
363
|
+
and client hold, so shared logic belongs there instead of in a util module. **This is the most commonly missed rule
|
|
364
|
+
in the codebase.** Collection-level helpers go `static` on the full model. Give any field whose business meaning is
|
|
365
|
+
not obvious a short trailing comment.
|
|
366
|
+
|
|
367
|
+
**A model instance's `Date` fields are prototype accessors, not own properties.** `user.createdAt` is still a `Dayjs`,
|
|
368
|
+
built on first read from a native `Date` the instance holds under a symbol. So `Object.keys(user)` and `{ ...user }`
|
|
369
|
+
do **not** include them, while `"createdAt" in user`, `for...in`, `JSON.stringify(user)`, `plainFieldsOf(user)`
|
|
370
|
+
(`akanjs/common`), `immerify`, and `deepObjectify` all do. To copy a model, `new cnst.X().set(user)`, never a spread.
|
|
446
371
|
|
|
447
372
|
**`<model>.document.ts`** — fixed order: `XFilter extends from(...)` → `X extends by(...)` → `XModel extends into(...)`,
|
|
448
373
|
with `sort: {}` always present. Chain methods validate → mutate → `return this`, and never `save()`; the caller saves,
|
|
@@ -450,9 +375,8 @@ so chains compose (`org.removeUser(id).removeInvite(id).save()`). Put a one-line
|
|
|
450
375
|
transition. Atomic counters live on the Model class with the updater-callback form, returning `!!modifiedCount`.
|
|
451
376
|
Indexes and derived totals go in `static override _onSchema`, not in the service. **Removal is always soft** — the
|
|
452
377
|
framework has no hard delete for a model table, and `delete` is deliberately left unused so it can mean one later.
|
|
453
|
-
The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`)
|
|
454
|
-
|
|
455
|
-
the count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)` and
|
|
378
|
+
The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`); only the
|
|
379
|
+
count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`. `updateById(id, update)` and
|
|
456
380
|
`removeById(id)` are those same query-level writes narrowed to one id, **not** the document path: they fire no hooks,
|
|
457
381
|
so a model whose removal cascades or carries a `_postRemove` still goes through `remove<Model>(id)`.
|
|
458
382
|
|
|
@@ -470,8 +394,8 @@ stores need none, because state and CRUD actions are generated. The body is thre
|
|
|
470
394
|
`this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
|
|
471
395
|
`this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
|
|
472
396
|
through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
|
|
473
|
-
nothing** (`no-return-in-store-action.grit`)
|
|
474
|
-
|
|
397
|
+
nothing** (`no-return-in-store-action.grit`). **Never `import type { RootStore } from "../st"`** — it crashes
|
|
398
|
+
`akan build` with a Bun SSR segfault.
|
|
475
399
|
|
|
476
400
|
**`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
|
|
477
401
|
`.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
|
|
@@ -494,27 +418,25 @@ workflow changes.
|
|
|
494
418
|
|
|
495
419
|
### Guards And Transports
|
|
496
420
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
guard or middleware.
|
|
507
|
-
|
|
508
|
-
`null` rather than a placeholder when genuinely unknown
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
- **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
neither again, and cleanup that must happen either way registers for both. From a `message` handler it belongs to
|
|
517
|
-
the socket and runs at close. A handler that throws is logged and never blocks the rest of the teardown.
|
|
421
|
+
Full contract — credential handshake, room revalidation, socket cleanup scoping: `get_guideline` with
|
|
422
|
+
`transportRule`, or `akan guideline show transportRule`.
|
|
423
|
+
|
|
424
|
+
- Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` instead of
|
|
425
|
+
branching on `getHttpContext()` / `getWebSocketContext()`, and keep them side-effect free and safe to re-run —
|
|
426
|
+
a pubsub room's guards are re-run whenever the socket's credential changes.
|
|
427
|
+
- Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is
|
|
428
|
+
unguarded unless it declares its own `guards` in its signal option.
|
|
429
|
+
- **Never read the caller's IP off the socket or the request peer — take `.with(Ip)`** (`context.getClientIp()`
|
|
430
|
+
inside a guard or middleware). Behind a federation gateway, `ws.remoteAddress` and the child's own peer are the
|
|
431
|
+
*gateway* (`127.0.0.1`) for every caller and for the whole life of every socket. `Ip` reads what a proxy
|
|
432
|
+
recorded, is unwrapped from its `::ffff:` form, and is `null` rather than a placeholder when genuinely unknown.
|
|
433
|
+
- **Every socket carries a `socketId`, and only the framework mints one** — read it off the `Ws` internal arg
|
|
434
|
+
(`.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`). Never mint your own from `ws.data`: the room
|
|
435
|
+
bookkeeping keys on the framework's id and a second one fails to match it silently. It identifies a
|
|
436
|
+
**connection**, not a caller, so per-user state keys on the account.
|
|
437
|
+
- **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it**
|
|
438
|
+
— a room for a `pubsub` subscribe, the socket for a `message` handler. Cleanup that must happen either way
|
|
439
|
+
registers for both.
|
|
518
440
|
|
|
519
441
|
### Authorization Defaults
|
|
520
442
|
|
|
@@ -536,35 +458,21 @@ workflow changes.
|
|
|
536
458
|
- `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.
|
|
537
459
|
- Bytes are `Binary`, never `Any` — see Scalar & Field Type Reference.
|
|
538
460
|
|
|
539
|
-
### Binary Pubsub
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
publisher is fixed rather than left corrupt. It is still not the contract: it queues rather than coalescing
|
|
555
|
-
(nobody declared lossy delivery for that room), and a Redis fan-out still JSON-stringifies it. Declare
|
|
556
|
-
`Binary`.
|
|
557
|
-
- The federation gateway relays a binary frame unchanged, and Bun IPC carries bytes as bytes, so the only hop
|
|
558
|
-
that needed teaching was the socket itself. A cross-server (Redis) fan-out encodes it as protobuf `bytes`.
|
|
559
|
-
|
|
560
|
-
### Mutation HTTP Verb
|
|
561
|
-
|
|
562
|
-
- A `mutation` is `POST`. `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several verbs
|
|
563
|
-
— a `query` GET and a `mutation` POST on the same custom `path` are mounted side by side. Two endpoints claiming
|
|
564
|
-
the same path **and** verb fail the boot rather than silently shadowing one another.
|
|
565
|
-
- Reach for it only when a foreign wire protocol forces the verb (a client you cannot change that sends
|
|
566
|
-
`PATCH /rest/v1/<table>`). Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
|
|
567
|
-
whatever is declared, so nothing needs restating per caller.
|
|
461
|
+
### Binary Pubsub And Mutation Verbs
|
|
462
|
+
|
|
463
|
+
Full contract — backpressure modes, the `Any`-carrying-bytes fallback, multi-verb paths: `get_guideline` with
|
|
464
|
+
`transportRule`.
|
|
465
|
+
|
|
466
|
+
- **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON envelope and the base64 a
|
|
467
|
+
JSON wire would need; the client's subscribe callback receives a `Uint8Array`. The optimization applies only
|
|
468
|
+
when the **whole** return is `Binary`. A declared `Binary` room **coalesces under backpressure** — keeping only
|
|
469
|
+
the newest frame — which is what telemetry and video want; declare `{ backpressure: "queue" }` when the frames
|
|
470
|
+
are a sequence a subscriber must see in full. A `pubsub(Any)` carrying bytes is framed too and warns once;
|
|
471
|
+
declare `Binary`.
|
|
472
|
+
- **A `mutation` is `POST`.** `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several
|
|
473
|
+
verbs; two endpoints claiming the same path **and** verb fail the boot. Reach for it only when a foreign wire
|
|
474
|
+
protocol forces the verb — Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
|
|
475
|
+
whatever is declared.
|
|
568
476
|
|
|
569
477
|
### Reserved Endpoint Names
|
|
570
478
|
|
|
@@ -573,74 +481,49 @@ workflow changes.
|
|
|
573
481
|
|
|
574
482
|
### Slices, Queries, and Hydration
|
|
575
483
|
|
|
484
|
+
Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `labelOf`, and the `q.exists` /
|
|
485
|
+
`q.missing` / `q.empty` split: `get_guideline` with `queryRule`, or `akan guideline show queryRule`.
|
|
486
|
+
|
|
576
487
|
- **The root slice (`""`, generated by `slice()`) takes `queryKey` and `args`, not a query.** `initX("byOwner",
|
|
577
488
|
[ownerId])` names one of the model's own filters and passes that filter's args; no key at all is the `any`
|
|
578
|
-
filter every model carries. A raw query descriptor
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
ref model's store — that store is a singleton, and loading into it would overwrite whatever listing of the same
|
|
586
|
-
model is already on screen. `ref` is the only UI hint a filter arg carries; there is no render callback, because a
|
|
587
|
-
function cannot cross to the client.
|
|
588
|
-
- **A summary counter names the listing it counts, on the field itself.** `field(Int).meta(getQueryMeta("user")
|
|
589
|
-
.query("byStatuses").args([["prepare"]]))` is what makes the dashboard tile clickable: clicking it applies that
|
|
590
|
-
filter to the listing in place — one request, no navigation — and fills the arg controls under the toolbar, so
|
|
591
|
-
the filter is visible and editable rather than opaque. `args` may be a thunk (`() => [dayjs().subtract(1,
|
|
592
|
-
"hour")]`), read at the click so a relative time is current. A meta naming a different model counts a different
|
|
593
|
-
listing, so its tile stays inert here. `AdminPanel`'s `queryMap` still overrides per column, and is now only for
|
|
594
|
-
a column whose field cannot declare one.
|
|
595
|
-
- **A picker row is labelled by `labelOf`, which reads `Light<Model>.label()` first.** Then the field carrying
|
|
596
|
-
`text: "title"`, then `title` / `name`, with the id as the floor. Write the method on the Light class — that is
|
|
597
|
-
where display logic belongs, and the same label is what `Load.Units` and the in-page agent's screen scope show.
|
|
598
|
-
A model with none renders ids and says so in the picker.
|
|
599
|
-
- A slice's `exec` returns a `QueryOf` (an opaque query descriptor, `pkgs/akanjs/constant/types.ts`); you **cannot** chain `.sort()`/`.limit()` on it.
|
|
600
|
-
- Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })` (`pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts`).
|
|
601
|
-
- Generated list accessors like `listBy(...)` return `Promise<Doc[]>`. For a chainable builder (`.sort().skip().limit().select()`) use the model facade's `findMany`/`findOne` (`FindManyChain`, `pkgs/akanjs/document/into.ts`).
|
|
602
|
-
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`); client fetch results are raw `GetStateObject` plain data (functions stripped, `pkgs/akanjs/base/types.ts`).
|
|
489
|
+
filter every model carries. A raw query descriptor could not survive the query string — `String(value)` sends
|
|
490
|
+
`[object Object]`. It is an admin API: `root:` is always `Admin`.
|
|
491
|
+
- A slice's `exec` returns a `QueryOf` (an opaque query descriptor); you **cannot** chain `.sort()`/`.limit()` on
|
|
492
|
+
it. Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })`. For a
|
|
493
|
+
chainable builder use the model facade's `findMany`/`findOne`.
|
|
494
|
+
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`);
|
|
495
|
+
client fetch results are raw `GetStateObject` plain data, functions stripped.
|
|
603
496
|
- Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
|
|
604
|
-
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
|
|
605
|
-
query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
|
|
606
|
-
model that carries no removal side effect; otherwise remove documents one at a time.
|
|
497
|
+
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
|
|
498
|
+
are query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
|
|
499
|
+
them on a model that carries no removal side effect; otherwise remove documents one at a time.
|
|
607
500
|
- **`update<Filter>` / `updateOne<Filter>` are chains, not calls:** `await updateInRoot(rootId).set({ status:
|
|
608
|
-
"archived" })`. The patch cannot trail the filter args
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
- **
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
- **"Has no value" is `q.empty`, never `q.missing`.** The three absence operators are distinct: `q.exists` is the
|
|
618
|
-
key being present, `q.missing` is the key being *absent from the stored JSON*, and `q.empty` is either — absent,
|
|
619
|
-
or present and null. An optional field is written both ways over its life: an insert that omits it leaves the key
|
|
620
|
-
out, while a document read back and saved carries the explicit `null` that the read materialized. So `q.missing`
|
|
621
|
-
matches a freshly inserted row and silently stops matching that same row after its first round-trip save. Reach
|
|
622
|
-
for it only to find rows written before the field was declared.
|
|
501
|
+
"archived" })`. The patch cannot trail the filter args, so it lands on a terminal `.set()`; building the chain
|
|
502
|
+
touches nothing.
|
|
503
|
+
- `removeOne` / `updateOne` hit the **newest** match, always, and report only counts — they are for "there is at
|
|
504
|
+
most one of these", not for claiming the next item off a queue.
|
|
505
|
+
- **A filter may not be keyed after its own model.** A filter `chat` on model `chat` would silently swap the
|
|
506
|
+
single-document `removeChat`/`updateChat` for a hookless query-level one; it throws at boot instead.
|
|
507
|
+
- **"Has no value" is `q.empty`, never `q.missing`.** `q.missing` is the key being *absent from the stored JSON*,
|
|
508
|
+
which a document written once and saved again no longer is. Reach for it only to find rows written before the
|
|
509
|
+
field was declared.
|
|
623
510
|
|
|
624
511
|
### Text Search In A Filter — `q.search()`
|
|
625
512
|
|
|
513
|
+
Full contract — relevance ordering, `columns` / `weights`: `get_guideline` with `queryRule`.
|
|
514
|
+
|
|
626
515
|
- Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
|
|
627
516
|
q.search(text, { prefix: true }))`. The generated `listBySearch` / `countBySearch` / `queryBySearch` /
|
|
628
517
|
`insightBySearch` come for free — you do **not** need a slice to make search usable.
|
|
629
518
|
- **Only add a search slice when the model's data is safe to enumerate.** A filter is server-side; a slice is a
|
|
630
|
-
client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk
|
|
631
|
-
table. Leave that decision to the mounting app.
|
|
519
|
+
client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk
|
|
520
|
+
the table. Leave that decision to the mounting app.
|
|
632
521
|
- `q.search()` compiles to a JOIN, not a WHERE fragment, so it **must sit at an AND position**. Nesting it under
|
|
633
|
-
`q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery
|
|
634
|
-
takes no join, so ignoring the node would silently widen the write to every other matching row.
|
|
522
|
+
`q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery`.
|
|
635
523
|
- Blank or whitespace-only input matches **nothing**. Never "fix" that into a passthrough: a passthrough turns a
|
|
636
524
|
search endpoint into a full listing.
|
|
637
|
-
- Order by relevance with the built-in `relevance` sort key.
|
|
638
|
-
|
|
639
|
-
compiler's own, not a model-defined default — redefining `latest` on the model does not change it.
|
|
640
|
-
- **A slice endpoint never reaches "unspecified".** The resolver fills `latest` before the query is built, so a
|
|
641
|
-
client asking for the score order has to name `relevance`; leaving `sort` off gets `latest`, not relevance.
|
|
642
|
-
- Scope a search with `columns` (`q.search(text, { columns: ["title"] })`) and re-weight with `weights`, a tuple of
|
|
643
|
-
finite numbers positional over `["title", "desc", "tag", "filter"]`.
|
|
525
|
+
- Order by relevance with the built-in `relevance` sort key. A slice endpoint never reaches the unspecified
|
|
526
|
+
fallback — leaving `sort` off gets `latest`, so a client wanting score order has to name `relevance`.
|
|
644
527
|
|
|
645
528
|
### Service / Signal Injection
|
|
646
529
|
|
|
@@ -664,8 +547,8 @@ export class AdminNoti extends adapt("adminNoti" as const, ({ use, env, plug, me
|
|
|
664
547
|
- 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`.
|
|
665
548
|
- **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.
|
|
666
549
|
- `this.logger` is provided; never construct a `Logger` inside an `adapt()` class. Lifecycle work goes in `override async onInit()`.
|
|
667
|
-
- **`adapt()` is for singletons only.** A per-use value object stays a plain class you `new` at the call site.
|
|
668
|
-
- **Legacy shape — recognise it, do not copy it.** Plain classes with `constructor(options: XOptions)`, registered in `lib/option.ts`
|
|
550
|
+
- **`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.
|
|
551
|
+
- **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.
|
|
669
552
|
|
|
670
553
|
Conventions that hold for both shapes:
|
|
671
554
|
|
|
@@ -686,54 +569,41 @@ Conventions that hold for both shapes:
|
|
|
686
569
|
### MCP Exposure
|
|
687
570
|
|
|
688
571
|
Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
|
|
689
|
-
follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An
|
|
690
|
-
that declares a real guard is published; one that declares none is refused, and so is a mutation whose
|
|
691
|
-
`Public`. The guards are already the authorization decision, so a second switch would say nothing
|
|
692
|
-
while guaranteeing that every endpoint added later is invisible to agents until somebody remembers
|
|
572
|
+
follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An
|
|
573
|
+
endpoint that declares a real guard is published; one that declares none is refused, and so is a mutation whose
|
|
574
|
+
only guard is `Public`. The guards are already the authorization decision, so a second switch would say nothing
|
|
575
|
+
they do not — while guaranteeing that every endpoint added later is invisible to agents until somebody remembers
|
|
576
|
+
it.
|
|
693
577
|
|
|
694
578
|
- **`mcp: false` keeps an endpoint off the shelf without touching its guards.** Guards answer "may an agent call
|
|
695
579
|
this"; they cannot answer "does this belong on a shelf", and a step of a UI-driven state machine
|
|
696
580
|
(`requestPhoneCodeForSignin`) is perfectly guarded and still a mistake for a model to reach. Write it in the
|
|
697
|
-
signal option (`mutation(Boolean, { guards: [Every], mcp: false })`, `init({ guards: [SignedIn], mcp: false })`
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
where it went. A plain 21-field model costs 12.6KB over eight entries; `mcp: { cru: false }` takes it to 4.7KB,
|
|
706
|
-
because the generated writes carry the input *and* the full model.
|
|
581
|
+
signal option (`mutation(Boolean, { guards: [Every], mcp: false })`, `init({ guards: [SignedIn], mcp: false })`),
|
|
582
|
+
or as an `mcp` map on `slice()` that **mirrors the `guards` map key for key** (`mcp: { cru: false }`) — root
|
|
583
|
+
slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, and it is
|
|
584
|
+
curation, not authorization: HTTP serves the endpoint exactly as before.
|
|
585
|
+
- **Narrow by cost, and read the boot log first.** MCP forbids a `$ref` across entries, so every entry inlines the
|
|
586
|
+
full schema of every model it mentions and the listing is re-sent whole to every agent that connects. A plain
|
|
587
|
+
21-field model costs 12.6KB over eight entries; `mcp: { cru: false }` takes it to 4.7KB. `MCP catalogue: … ·
|
|
588
|
+
listing 214KB` plus a per-signal `MCP catalogue cost:` line says where it went.
|
|
707
589
|
- Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
|
|
708
|
-
allowedOrigins, pageSize, language, auth })`, **not `main.ts`**.
|
|
709
|
-
the
|
|
710
|
-
- **RFC 9728 metadata is served only once an authorization server is named.** Advertising `bearer_methods_supported`
|
|
711
|
-
without one makes Cursor (and similar clients) force OAuth. Set `AKAN_MCP_AUTH_SERVERS` when the surface is an
|
|
712
|
-
OAuth resource; otherwise the well-known document is absent.
|
|
713
|
-
- **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real guard (`[Public]` is
|
|
714
|
-
having none), `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and
|
|
715
|
-
the generated `light<Model>` read — it is `<Model>` in a smaller shape, so one read never costs two entries of
|
|
716
|
-
every listing. A `prompt` also refuses a list argument and any `Any` argument.
|
|
717
|
-
- **Every refusal is named in the boot log**, with a `MCP catalogue: tools=… prompts=…` count. Read that line
|
|
718
|
-
first when a tool you expected is missing — with no opt-in to notice, it is the only place the answer exists.
|
|
719
|
-
The boot log also names every published entry with no dictionary `.desc()`: an agent picks a tool by its
|
|
720
|
-
description, so write the model `.desc()` the generated entries borrow.
|
|
721
|
-
- A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
|
|
722
|
-
generalized to `You are not permitted to perform this action.` Never make either message more helpful — the
|
|
723
|
-
difference is what enumerates your private surface.
|
|
724
|
-
- **A `field.visual` field is stripped from every MCP result**, and from the readable schema so the two agree — the
|
|
725
|
-
model-level answer to a field the page needs and an agent does not. `resolveReturn` leaves it alone, so the
|
|
726
|
-
browser still receives it.
|
|
727
|
-
- **A structured result ships twice by default** — once as `structuredContent`, once as the same JSON in the text
|
|
728
|
-
block, which is what the spec asks for clients predating the structured field and is also a flat doubling of what
|
|
729
|
-
every model-returning tool costs. `option.setMcp({ legacyTextBlock: false })` leaves a pointer there instead.
|
|
730
|
-
- `AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch; `AKAN_MCP=false` takes the
|
|
590
|
+
allowedOrigins, pageSize, language, auth })`, **not `main.ts`**. Each field has an `AKAN_MCP_*` env spelling.
|
|
591
|
+
`AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch; `AKAN_MCP=false` takes the
|
|
731
592
|
whole surface off.
|
|
593
|
+
- **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
|
|
594
|
+
guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
|
|
595
|
+
generated `light<Model>` read. A `prompt` also refuses a list argument and any `Any` argument. **Every refusal
|
|
596
|
+
is named in the boot log**, along with every published entry missing a dictionary `.desc()` — an agent picks a
|
|
597
|
+
tool by its description.
|
|
598
|
+
- A refused endpoint answers the *same* "unknown tool" as one that does not exist, and a guard's refusal is
|
|
599
|
+
generalized. Never make either message more helpful — the difference is what enumerates your private surface.
|
|
600
|
+
- **A `field.visual` field is stripped from every MCP result** and from the readable schema so the two agree.
|
|
601
|
+
- **A structured result ships twice by default** — as `structuredContent` and as the same JSON in the text block.
|
|
602
|
+
`option.setMcp({ legacyTextBlock: false })` leaves a pointer there instead.
|
|
732
603
|
- **`prompt()`** is invoked by the *user* as a slash command, never chosen by the model. `exec` returns
|
|
733
604
|
`PromptMessage[]` built with `Msg.user` / `Msg.assistant` / `Msg.resource` / `Msg.image`; an embedded payload is
|
|
734
|
-
masked by the model you name
|
|
735
|
-
|
|
736
|
-
whether or not MCP is enabled, so guard it like any other read.
|
|
605
|
+
masked by the model you name, and one whose `hidden`/`secret` fields are populated with no model named is
|
|
606
|
+
refused. It is also mounted as a plain HTTP `GET` whether or not MCP is enabled, so guard it like any other read.
|
|
737
607
|
|
|
738
608
|
Full contract — configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions, and
|
|
739
609
|
`McpProgress.report`: `get_guideline` with `mcpRule`, or `akan guideline show mcpRule`.
|
|
@@ -747,64 +617,34 @@ screen does not offer the user is not one an agent may pull in their place. The
|
|
|
747
617
|
package; apps and libs never import it directly (`no-import-external-library`) — everything reaches them through
|
|
748
618
|
`st.*` and `akanjs/ui`.
|
|
749
619
|
|
|
620
|
+
Read the full contract before building an agent surface — the chat's own options, `st.tool` / `st.expose` /
|
|
621
|
+
`st.useState` in full, zones and namespaces, attachments and speech, slash commands, transcript compaction, and
|
|
622
|
+
the built-in tool semantics: `get_guideline` with `agentRule`, or `akan guideline show agentRule`. What follows is
|
|
623
|
+
what every developer must know even when not building one.
|
|
624
|
+
|
|
750
625
|
- **Mount `<Agent.Chat />` once in a layout.** The endpoint is a stateless relay that **never executes tools**:
|
|
751
626
|
every tool runs in the caller's own browser session, gated by guards and the approval card. Its guard is
|
|
752
627
|
`AgentRelayAccess`, which refuses every call until the app names a guard of its own —
|
|
753
|
-
`option.setAgentAccess(SignedIn)`.
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
`.arg()` is an argument the caller must pass and `.opt()` one it may, and `.exec()` is the only hook. Reach a
|
|
760
|
-
store action from the body (`.exec((id) => st.do.removeX(id))`); `st.do` on its own reaches nobody.
|
|
761
|
-
- **Publish a tool only where the screen already renders the control.** A falsy name declares the tool without
|
|
762
|
-
publishing it, which is how a conditional surface stays legal — `.exec()` is a hook, so withhold the name, not
|
|
763
|
-
the call. A `disabled` control publishes nothing.
|
|
764
|
-
- **A component that renders once per row takes the row id as an argument**, never a closure over it — fifty
|
|
765
|
-
registrations of one name leave forty-nine shadowed. The description is what makes the repeats one declaration:
|
|
766
|
-
same name and same `.desc()` is the same tool registered fifty times, and only a second description under that
|
|
767
|
-
name warns.
|
|
768
|
-
- **`{ settle: false }` is the read that returns what is already there.** Every other tool is waited out before
|
|
769
|
-
what it did to the screen is reported, because a write may still be landing when `exec` resolves. `{ confirm }`
|
|
770
|
-
and `{ guard }` ride the same object; a `remove*` name confirms by default.
|
|
771
|
-
- **A component that can render twice on one screen takes a `namespace` prop** (`Tab`, `Dialog`, `Dropdown`,
|
|
772
|
-
`ScreenNavigator`) and publishes nothing without one.
|
|
628
|
+
`option.setAgentAccess(SignedIn)`. **The LLM is configured in `option.ts`, never through the environment**
|
|
629
|
+
(`option.setLlm({ apiKey, model, host })`).
|
|
630
|
+
- **Declare the tool beside the control that already does it**, never as a separate surface:
|
|
631
|
+
`st.tool("x").desc("…").arg("id", ID).exec(fn)` returns the callable to hand to `onClick` — one handler for the
|
|
632
|
+
person and the agent. Publish it only where the screen already renders the control; a falsy name declares the
|
|
633
|
+
tool without publishing it, and a `disabled` control publishes nothing.
|
|
773
634
|
- **Forms publish themselves**: a `Field.*` / `Input.*` / `Select` / `Switch` handed `onChange={st.do.setXOnY}`
|
|
774
635
|
**by reference** publishes that setter, and `st.use.yForm()` adds `fillYForm(patch)`. An inline arrow publishes
|
|
775
636
|
nothing — normalize with the control's `transform` prop, and multi-write with a `_postSet<Field>` store method.
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
a derived value and `st.useState("tab", String, { set: true }).desc("…").init("all")` for local state — the type
|
|
780
|
-
typechecks what is handed over and decides how it is read, so a model class masks by that model. `Any` is the
|
|
781
|
-
escape hatch: no typecheck, and the value passes untouched. `.value()` also takes a thunk, read when the agent
|
|
782
|
-
reads.
|
|
637
|
+
This is the same rule `no-unpublished-form-setter.grit` enforces.
|
|
638
|
+
- **Reading is per key, not per store.** `st.use.x({ agent: false })` keeps a subscribed key off the surface; a key
|
|
639
|
+
no component reads is unreadable. A value with populated `hidden`/`secret` fields is refused at read.
|
|
783
640
|
- **Return what answers the question, not the record.** A tool's value is capped at 20,000 characters before it
|
|
784
|
-
enters the transcript and
|
|
785
|
-
|
|
786
|
-
and from there it rides *every* later turn. Each tool row in the chat carries its own estimated token cost and
|
|
787
|
-
opens onto the value the model was handed — that row is where a conversation that filled up in four messages
|
|
788
|
-
explains itself. A field that is bulky and useless to a model is fixed once at the model instead:
|
|
789
|
-
`field.visual(String)` keeps it on the page and out of every agent-facing read and MCP result.
|
|
790
|
-
- **`<Agent.Skip label="site footer">` leaves a region out of the default `readScreen`** — a footer, a cookie
|
|
791
|
-
banner, a nav that repeats on every route. `[skipped: site footer]` stands in its place rather than nothing, since
|
|
792
|
-
a deleted region reads as an absent one, and the marker's name is a `section` that reads the region on request.
|
|
793
|
-
Put `data-agent-skip="<name>"` on the element the page already renders where a wrapper div would move the layout.
|
|
794
|
-
It hides **text, not behaviour**: an `st.tool` inside is published as before. Narrowing from the other side —
|
|
795
|
-
`Agent.Zone`, `readScreen({ section })` — is the better move on a screen that is mostly chrome.
|
|
641
|
+
enters the transcript, and once there it rides *every* later turn. A field that is bulky and useless to a model
|
|
642
|
+
is fixed once at the model instead: `field.visual(String)`.
|
|
796
643
|
- **Route guidance is `<Agent.Guide instructions="..." />`** rendered from a `_layout.tsx` or page — the render
|
|
797
644
|
tree is the cascade. It is a component, not a `pageConfig` field, and `*.abstract.md` is never served to agents.
|
|
798
|
-
- The framework publishes five built-ins on every store surface — `navigate`, `goBack`, `readScreen`,
|
|
799
|
-
`readState(key)`, `highlight(target)` — plus `askUser`, which the session owns. **There is no general-purpose
|
|
800
|
-
wait**: publish an `st.tool` beside the control that starts the work and let it await the work.
|
|
801
645
|
- **Model-facing text is English, always** — every `.desc()`, `instructions`, Guide text. The `l()` rule covers
|
|
802
646
|
strings a *user* reads.
|
|
803
647
|
|
|
804
|
-
Full contract — the chat's own options, attachments and speech, zones, what `akanjs/ui` publishes for you, slash
|
|
805
|
-
commands, transcript compaction, and the built-in tool semantics: `get_guideline` with `agentRule`, or
|
|
806
|
-
`akan guideline show agentRule`.
|
|
807
|
-
|
|
808
648
|
## Scalar Modeling (`**/*.constant.ts`)
|
|
809
649
|
|
|
810
650
|
- Define Akan models in `.constant.ts` files with `via` from `akanjs/constant`.
|
|
@@ -833,34 +673,22 @@ commands, transcript compaction, and the built-in tool semantics: `get_guideline
|
|
|
833
673
|
|
|
834
674
|
### Text Search Fields — the `text` role
|
|
835
675
|
|
|
836
|
-
|
|
837
|
-
|
|
676
|
+
Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH_ENABLED`: `get_guideline` with
|
|
677
|
+
`queryRule`.
|
|
678
|
+
|
|
679
|
+
- A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and
|
|
680
|
+
likewise `"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model
|
|
681
|
+
switch. Declaring roles is all the wiring there is.
|
|
838
682
|
- Pick the role by what the value *is*, because `bm25` weights them positionally (`title` 10, `tag` 3, `desc` 1,
|
|
839
683
|
`filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
|
|
840
|
-
scoping value (status, owner, role) that must be matchable but must never outrank a real title hit.
|
|
841
|
-
|
|
684
|
+
scoping value (status, owner, role) that must be matchable but must never outrank a real title hit. `thumb` is
|
|
685
|
+
mirrored for rendering a hit and is **not** indexed — never expect it to match.
|
|
842
686
|
- **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
|
|
843
|
-
mirror is plaintext and an indexed secret would leak through search. The
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
carries a role is rejected at the parent, not silently indexed; that one is only reachable at runtime, since the
|
|
848
|
-
pairing spans two files.
|
|
849
|
-
- The role works on a relation too (`image: field(File, { text: "thumb" })`) and on an array (`playing: field([String],
|
|
850
|
-
{ text: "tag" })`); an array of objects indexes by leaf key, including an array leaf (`works[*].tags`). A field
|
|
851
|
-
inside a `Map` indexes nothing: there is no fixed path to extract it from.
|
|
852
|
-
- Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
|
|
853
|
-
because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
|
|
854
|
-
path.
|
|
687
|
+
mirror is plaintext and an indexed secret would leak through search. The same refusal throws at runtime as a
|
|
688
|
+
backstop, including for a `text` field *underneath* one of those. Do not work around either.
|
|
689
|
+
- The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
|
|
690
|
+
indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
|
|
855
691
|
- Search runs on sqlite/libsql only. `q.search()` against Postgres throws, loudly, rather than returning every row.
|
|
856
|
-
- `AKAN_SEARCH_ENABLED=0` switches the index off process-wide; unset means on. It never deletes mirror data, and
|
|
857
|
-
re-enabling reconciles every ref. **Give every process the same value** — a process cannot drop triggers for models
|
|
858
|
-
it does not mount, so a mixed fleet leaves stale triggers behind.
|
|
859
|
-
- The tokenizer is `AKAN_SEARCH_TOKENIZER` (or `database.search.tokenizer`, which wins), defaulting to
|
|
860
|
-
`unicode61 remove_diacritics 2`. Changing it rebuilds the index from the mirror on the next boot — the model
|
|
861
|
-
tables are never re-read — so it is a safe knob, unlike a `text` role change, which re-reads every row. The
|
|
862
|
-
rebuild takes no cross-process claim, so a fleet restarted at once repeats it in every process; stagger the
|
|
863
|
-
restart when the mirror is large.
|
|
864
692
|
|
|
865
693
|
### Image & File Fields
|
|
866
694
|
|
|
@@ -871,37 +699,28 @@ commands, transcript compaction, and the built-in tool semantics: `get_guideline
|
|
|
871
699
|
|
|
872
700
|
### Cascade Remove — the `cascade` option
|
|
873
701
|
|
|
702
|
+
Full contract — bulk planning, the auto-created index, cycle cutting, boot-time validation: `get_guideline` with
|
|
703
|
+
`queryRule`.
|
|
704
|
+
|
|
874
705
|
**The value names the direction, and getting it wrong is a data loss.** The two actions can sit on the same field
|
|
875
706
|
shape, so `cascade` never means "related" — it means one of exactly these:
|
|
876
707
|
|
|
877
708
|
- `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
|
|
878
|
-
`image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it
|
|
879
|
-
`ID`, and a scalar each fail the class build.
|
|
709
|
+
`image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it.
|
|
880
710
|
- `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
|
|
881
|
-
its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three
|
|
882
|
-
a relation
|
|
883
|
-
`refPath
|
|
884
|
-
- **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time
|
|
885
|
-
model's removal would have to sweep the polymorphic table on the chance it is the owner.
|
|
711
|
+
its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three
|
|
712
|
+
forms: a relation, an id with `ref`, or a polymorphic id with `refPath`. An array, a Map, `ref` together with
|
|
713
|
+
`refPath`, and a field naming no owner each fail the class build.
|
|
714
|
+
- **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time.
|
|
886
715
|
- **A cascade goes through the target's service, never its model** — that path is what runs the target's
|
|
887
716
|
`_postRemove`, where a module puts the side effect the removal has to carry.
|
|
888
|
-
- **
|
|
889
|
-
`
|
|
890
|
-
`_postRemove` silently flips it back to one document at a time — the boot log is the only place that shows.
|
|
891
|
-
- **The plan is sealed after every service is live**, so a `listenPost("remove")` registered in `onInit` counts,
|
|
892
|
-
and an unmounted `removeRef` target or `removeWith` owner fails the boot rather than the first removal. An
|
|
893
|
-
unmounted `refPath` candidate only warns, since that list spans optional modules by design.
|
|
894
|
-
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped
|
|
895
|
-
by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
|
|
717
|
+
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
|
|
718
|
+
`origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
|
|
896
719
|
- Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
|
|
897
720
|
restorable, and reviving the owner does not revive what went with it.
|
|
898
|
-
- A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
|
|
899
|
-
polymorphic); every non-base field lives in the `_doc` JSON column, so the lookup would otherwise scan the table.
|
|
900
721
|
- **Query-level removes fire no hooks and therefore no cascade** — `removeManyByQuery` / `updateManyByQuery`, the
|
|
901
722
|
generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
|
|
902
723
|
at a time when it cascades.
|
|
903
|
-
- Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write. Cycles are cut by a
|
|
904
|
-
visited set carried down the whole chain, with a depth cap of 16.
|
|
905
724
|
|
|
906
725
|
## Akan Page Routing (`apps/**/page/**`)
|
|
907
726
|
|