@akanjs/cli 3.0.0-beta.2 → 3.0.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.build-stamp +1 -1
- package/CodeAgent-q2m47rqp.js +9 -0
- package/CodeAgentRpcHost-p7h6n2hp.js +7 -0
- package/CodeAgentRpcListener-7p7z5dd7.js +97 -0
- package/CodeTui-pdqhc3sn.js +1457 -0
- package/README.md +3 -0
- package/TunnelShare-scf4e4pd.js +9 -0
- package/{agent.command-d2gfxfck.js → agent.command-ttn09rx3.js} +8 -9
- package/akanCode.js +102 -0
- package/application.command-7gweenv7.js +179 -0
- package/{applicationBuildRunner-kxwbssgn.js → applicationBuildRunner-38exmejd.js} +63 -64
- package/buildBatch.proc.js +26 -25
- package/cloud.command-bhztw7c2.js +95 -0
- package/cloud.script-aabyhgba.js +20 -0
- package/code.command-6s8c0k91.js +154 -0
- package/commandManifest.json +1 -1
- package/consoleToStderr-h5bryj94.js +7 -0
- package/context.command-phmgywt2.js +48 -0
- package/context.runner-yvyw5g2d.js +21 -0
- package/{dependencyScanner-shs82ynp.js → dependencyScanner-ye2e6pdz.js} +2 -2
- package/{devTui-40ggmd99.js → devTui-q7k7bakd.js} +122 -40
- package/fileSys-5ngz93ga.js +9 -0
- package/guideline.command-7jk47fgq.js +42 -0
- package/guidelines/agentRule/agentRule.instruction.md +209 -28
- package/guidelines/conventions/conventions.instruction.md +96 -29
- package/guidelines/fieldRule/fieldRule.instruction.md +1 -0
- package/guidelines/mcpRule/mcpRule.instruction.md +7 -5
- package/guidelines/queryRule/queryRule.instruction.md +17 -7
- package/guidelines/runtimeRule/runtimeRule.instruction.md +77 -3
- package/guidelines/ssrRule/ssrRule.instruction.md +4 -2
- package/guidelines/transportRule/transportRule.instruction.md +51 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +66 -3
- package/incrementalBuilder.proc.js +31 -48
- package/{index-h7yw2z9n.js → index-0bq14vr1.js} +8 -14
- package/{index-259dsnz3.js → index-1db2cw0y.js} +5 -8
- package/index-22d57v4c.js +266 -0
- package/index-3hx1z96c.js +87 -0
- package/{index-kcxmv48c.js → index-3t7g9e4r.js} +45 -79
- package/{index-ne2qy0nk.js → index-4dn1p115.js} +12 -12
- package/{index-q0y2jq9j.js → index-5b6tavv3.js} +6 -3
- package/index-7cqjvraf.js +69 -0
- package/index-7md2g9vw.js +6 -0
- package/{index-zry5nz81.js → index-8kb5p54p.js} +51 -30
- package/{index-ktb5njgy.js → index-9629w7ns.js} +49 -99
- package/index-9dp031by.js +12 -0
- package/{index-a64esmtb.js → index-aadpbnt2.js} +172 -308
- package/{index-3d9y066c.js → index-bjwxs904.js} +9 -8
- package/index-c6eggbee.js +123 -0
- package/index-d0arnrm8.js +700 -0
- package/index-d2242ksy.js +99 -0
- package/{index-600zhnve.js → index-dg97qp24.js} +70 -200
- package/{index-92zztqy1.js → index-dy0jx9ay.js} +2 -17
- package/{index-w8kmx1b6.js → index-fgq75haa.js} +198 -290
- package/{index-2tv3fdx4.js → index-ga6hygxr.js} +944 -1333
- package/{index-by5gpp8x.js → index-hj6w4ka1.js} +2 -1
- package/index-hr5n7t4x.js +465 -0
- package/{index-hwkygn9s.js → index-khpfpcsd.js} +302 -33
- package/{index-7737gxcp.js → index-m0ms3n1c.js} +107 -241
- package/{index-ws5e0j51.js → index-mgfx3mfs.js} +43 -26
- package/index-p6pbz9n5.js +25 -0
- package/index-pp54p5zk.js +53 -0
- package/{index-j9yhg404.js → index-q44jbw48.js} +4 -7
- package/{index-mak3cd28.js → index-rmcw8yc8.js} +137 -255
- package/{index-pnqbkf5p.js → index-rnt66vym.js} +6 -2
- package/index-smme4r47.js +49 -0
- package/index-tgjy8xf3.js +187 -0
- package/index-v5eayqqw.js +2729 -0
- package/index-wa5jbxj1.js +760 -0
- package/{index-4edzf1gr.js → index-wp43xrjv.js} +77 -150
- package/{index-fz5q3gd7.js → index-ya5qbmyg.js} +10 -17
- package/index-yjkte9aj.js +1119 -0
- package/{index-gyd220p2.js → index-yy4s1j72.js} +554 -577
- package/{index-v0qh106m.js → index-z50k041j.js} +580 -862
- package/index.js +23 -22
- package/{library.command-nbsky672.js → library.command-ccb1cp92.js} +4 -5
- package/{localRegistry.command-yeq57wjr.js → localRegistry.command-ajb9n87j.js} +23 -42
- package/{module.command-29vqzrh8.js → module.command-5k6h407d.js} +8 -11
- package/{package.command-axv0abmn.js → package.command-e6ntvpez.js} +6 -7
- package/package.json +14 -6
- package/{page.command-vs9s3zrh.js → page.command-v4f90wb8.js} +4 -5
- package/{primitive.command-cbxmqbtc.js → primitive.command-g6km57q5.js} +9 -22
- package/prompter-ptaa3nk0.js +8 -0
- package/{quality.command-gmbs7nws.js → quality.command-de3p36pz.js} +205 -377
- package/{recipeScanner-fpagtgc1.js → recipeScanner-4b69ybe0.js} +1 -1
- package/{repair.command-bqsjptsj.js → repair.command-d3x1javq.js} +11 -35
- package/{routeSourceValidator-apyqw7wk.js → routeSourceValidator-7ycnfz0e.js} +0 -6
- package/{scalar.command-6k1vhe9s.js → scalar.command-7ezrac2b.js} +7 -10
- package/skills/akan-code/SKILL.md +130 -0
- package/skills/akan-debug/SKILL.md +42 -0
- package/skills/akan-deps/SKILL.md +50 -0
- package/skills/akan-err/SKILL.md +39 -0
- package/skills/akan-module/SKILL.md +45 -0
- package/skills/akan-scaffold/SKILL.md +76 -0
- package/skills/akan-scalar/SKILL.md +52 -0
- package/skills/akan-service/SKILL.md +34 -0
- package/skills/akan-store/SKILL.md +67 -0
- package/skills/akan-ui/SKILL.md +63 -0
- package/skills/akan-validate/SKILL.md +44 -0
- package/{subspace.command-fwjfcts7.js → subspace.command-ps71ynp6.js} +138 -67
- package/templates/__scalar/__model__/__model__.abstract.md.template +8 -0
- package/templates/appSample/lib/__scalar/workHistory/workHistory.abstract.ts +8 -39
- package/templates/appSample/lib/_noti/noti.abstract.ts +6 -29
- package/templates/appSample/lib/task/task.abstract.ts +12 -32
- package/templates/appSample/lib/task/task.signal.spec.ts +15 -18
- package/templates/appSample/lib/task/task.signal.test.ts +15 -1
- package/templates/appSample/lib/task/task.signal.ts +1 -1
- package/templates/appSample/srvkit/{AuthGuard.ts → guards.ts} +7 -4
- package/templates/appSample/srvkit/{SessionInternalArg.ts → internalArgs.ts} +6 -4
- package/templates/client.ts +7 -30
- package/templates/crudPages/[__model__Id]/edit/page.tsx +2 -1
- package/templates/crudPages/[__model__Id]/page.tsx +2 -1
- package/templates/crudPages/new/page.tsx +2 -1
- package/templates/crudPages/page.tsx +2 -1
- package/templates/crudSinglePage/page.tsx +2 -1
- package/templates/lib/__lib/lib.dictionary.ts +0 -2
- package/templates/lib/__lib/lib.document.ts +0 -2
- package/templates/lib/sig.ts +8 -30
- package/templates/localDev/docker-compose.yaml.template +3 -14
- package/templates/module/__model__.signal.ts +12 -4
- package/templates/pkgRoot/tsconfig.json.template +2 -7
- package/templates/server.ts +0 -2
- package/templates/service/__model__.abstract.md.template +8 -0
- package/templates/workspaceRoot/.gitignore.template +0 -1
- package/tunnel.command-hsrv2mny.js +129 -0
- package/{typeChecker-3xgrzc2v.js → typeChecker-px49mezc.js} +1 -1
- package/typecheck.proc.js +1 -1
- package/workflow.command-z2p32nmf.js +79 -0
- package/{workspace.command-8s050m0h.js → workspace.command-tmdqkrh0.js} +77 -109
- package/abstractCompactor-mjh5ppsq.js +0 -101
- package/application.command-pj2m7584.js +0 -204
- package/applicationReleasePackager-4yasxwv9.js +0 -244
- package/capacitorApp-36rbr858.js +0 -12
- package/cloud.command-jshwdq22.js +0 -93
- package/context.command-ez8a0zyz.js +0 -80
- package/devTuiView-c08ccbat.js +0 -9
- package/getDirname-dr3jz1xg.js +0 -7
- package/guideline.command-xmqwx8zm.js +0 -359
- package/guidelines/agentRule/agentRule.generate.json +0 -31
- package/guidelines/componentRule/componentRule.generate.json +0 -31
- package/guidelines/conventions/conventions.generate.json +0 -25
- package/guidelines/cssRule/cssRule.generate.json +0 -31
- package/guidelines/docPageRule/docPageRule.generate.json +0 -31
- package/guidelines/docPageRule/docPageRule.instruction.md +0 -25
- package/guidelines/docSyncRule/docSyncRule.generate.json +0 -31
- package/guidelines/docSyncRule/docSyncRule.instruction.md +0 -25
- package/guidelines/enumConstant/enumConstant.generate.json +0 -31
- package/guidelines/enumConstant/enumConstant.instruction.md +0 -28
- package/guidelines/fieldRule/fieldRule.generate.json +0 -31
- package/guidelines/framework/framework.generate.json +0 -32
- package/guidelines/mcpRule/mcpRule.generate.json +0 -35
- package/guidelines/modelConstant/modelConstant.generate.json +0 -32
- package/guidelines/modelDictionary/modelDictionary.generate.json +0 -32
- package/guidelines/modelDocument/modelDocument.generate.json +0 -31
- package/guidelines/modelService/modelService.generate.json +0 -31
- package/guidelines/modelSignal/modelSignal.generate.json +0 -31
- package/guidelines/modelStore/modelStore.generate.json +0 -31
- package/guidelines/modelTemplate/modelTemplate.generate.json +0 -31
- package/guidelines/modelUnit/modelUnit.generate.json +0 -31
- package/guidelines/modelUtil/modelUtil.generate.json +0 -31
- package/guidelines/modelView/modelView.generate.json +0 -31
- package/guidelines/modelZone/modelZone.generate.json +0 -31
- package/guidelines/moduleCodegen/moduleCodegen.generate.json +0 -31
- package/guidelines/moduleCodegen/moduleCodegen.instruction.md +0 -28
- package/guidelines/moduleOverview/moduleOverview.generate.json +0 -31
- package/guidelines/queryRule/queryRule.generate.json +0 -31
- package/guidelines/recipeRule/recipeRule.generate.json +0 -31
- package/guidelines/runtimeRule/runtimeRule.generate.json +0 -25
- package/guidelines/scalarConstant/scalarConstant.generate.json +0 -32
- package/guidelines/scalarDictionary/scalarDictionary.generate.json +0 -32
- package/guidelines/scalarModule/scalarModule.generate.json +0 -31
- package/guidelines/sharedUiUsage/sharedUiUsage.generate.json +0 -31
- package/guidelines/sharedUiUsage/sharedUiUsage.instruction.md +0 -25
- package/guidelines/ssrRule/ssrRule.generate.json +0 -31
- package/guidelines/transportRule/transportRule.generate.json +0 -31
- package/guidelines/utilUiUsage/utilUiUsage.generate.json +0 -31
- package/guidelines/utilUiUsage/utilUiUsage.instruction.md +0 -25
- package/guidelines/workspaceOnboarding/workspaceOnboarding.generate.json +0 -24
- package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +0 -33
- package/index-2hq52jq3.js +0 -465
- package/index-47pj3aee.js +0 -76
- package/index-6wwzs1bc.js +0 -7
- package/index-8pm30m73.js +0 -841
- package/index-kn1h7p1x.js +0 -560
- package/index-meh558jy.js +0 -65
- package/index-rwg2aa4f.js +0 -229
- package/index-wt7qrtd4.js +0 -1516
- package/templates/__scalar/__model__/__model__.abstract.md +0 -28
- package/templates/module/__model__.abstract.md +0 -28
- package/templates/service/__model__.abstract.md +0 -28
- package/workflow.command-nwpcdc8p.js +0 -107
|
@@ -28,7 +28,7 @@ you fetch on demand — `get_guideline` with the name, or `akan guideline show <
|
|
|
28
28
|
| name | covers |
|
|
29
29
|
|---|---|
|
|
30
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 |
|
|
31
|
+
| `runtimeRule` | `web` / `csr` surfaces, gateway vs solo processes, logging, the generated image, shipped assets, database modes |
|
|
32
32
|
| `queryRule` | slices and hydration, the generated filter methods, full-text search, cascade removal |
|
|
33
33
|
| `transportRule` | guards across HTTP and websocket, socket identity and cleanup, binary pubsub, mutation verbs |
|
|
34
34
|
| `mcpRule` | MCP configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions |
|
|
@@ -76,6 +76,18 @@ back.
|
|
|
76
76
|
`*.constant.ts`, `*.store.ts`, and the five module component suffixes (`no-bang-comment-in-client.grit`). Bun
|
|
77
77
|
classifies `//!` and `/*!` as legal comments and keeps them through minification, so the note ships to every
|
|
78
78
|
visitor. Use `// FIXME:` there; `//!` stays legal in server, `srvkit/`, and CLI files.
|
|
79
|
+
- **Never touch `document.cookie`, `localStorage` or `sessionStorage` in app or lib code** (`no-document-cookie.grit`,
|
|
80
|
+
`no-web-storage.grit`). A native shell serves the page from `app://localhost` on iOS, macOS and Linux, which keeps
|
|
81
|
+
no cookies: `document.cookie` reads `""` and a write is dropped, so the value exists in the browser and vanishes in
|
|
82
|
+
the app. Web Storage does work in the shells, but it bypasses the store akanjs picks per platform (the shell's
|
|
83
|
+
Preferences in an app) and throws during SSR. Use `getCookie` / `setCookie` / `removeCookie`, `storage`, and
|
|
84
|
+
`secretStorage` for tokens and credentials, all from `akanjs/client`.
|
|
85
|
+
- **Browser APIs an app WebView lacks live in `webkit/`** (`no-web-only-api-outside-webkit.grit`, a warning):
|
|
86
|
+
`navigator.share` / `canShare` (Android's WebView has none), `navigator.serviceWorker` and
|
|
87
|
+
`Notification.requestPermission` / `.permission` (no app page has them), `navigator.geolocation` (use
|
|
88
|
+
`useGeoLocation` from `akanjs/webkit`) and `navigator.vibrate` (iOS has none; use `haptics`). They are legal on the
|
|
89
|
+
web, so the rule does not ban them: it keeps them in a `webkit/` hook that branches on `isNativeApp()` from
|
|
90
|
+
`akanjs/client/native`, which is where the app side goes too.
|
|
79
91
|
- **Never return a value from a store action** (`no-return-in-store-action.grit`). Every method of a `store(...)`
|
|
80
92
|
class dispatches through `st.do.<action>()`, typed `void` / `Promise<void>`, so the value is unreachable — write
|
|
81
93
|
it into state with `this.set({ ... })`. A bare `return;` guard, a `return` inside a nested callback, a getter,
|
|
@@ -216,6 +228,8 @@ Do not narrate code. Do document the thing the code cannot say. Both halves are
|
|
|
216
228
|
## Client / Server Boundaries (`apps/**`, `libs/**`, `pkgs/akanjs/**`)
|
|
217
229
|
|
|
218
230
|
- Use `"use client";` at the top of client component files.
|
|
231
|
+
- A `"use client"` file re-exports by name (`export { A, B } from "./x"`), never with `export *`: the server knows a
|
|
232
|
+
client module only by the export names it spells out, so `akan start` and `akan build` stop on a star re-export.
|
|
219
233
|
- Be careful when importing client-only code from page or layout modules.
|
|
220
234
|
- Keep page props serializable unless the existing route pattern clearly allows otherwise.
|
|
221
235
|
- In domain UI the boundary is mechanical, not a judgment call: `Template`, `Zone`, and `Util` are always client components with `"use client"` on line 1; `Unit` and `View` are always server components and never carry the directive.
|
|
@@ -273,8 +287,10 @@ are, because those are the ones the server could have done.
|
|
|
273
287
|
SEO snapshots, prerendering and pre-hydration E2E read, so await what the page needs immediately and stream
|
|
274
288
|
the rest. `xListInY` / `xInsightInY` hold hydrated model instances that React Flight refuses as client props:
|
|
275
289
|
consume them in a server component, never as a `Zone` prop.
|
|
276
|
-
6. **Use named `ReactNode` slots, not just `children`** —
|
|
277
|
-
|
|
290
|
+
6. **Use named `ReactNode` slots, not just `children`** — a client shell that takes one slot per region lets a
|
|
291
|
+
server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
|
|
292
|
+
declares `title` / `left` / `right` alongside `back` and `children`, but renders only the last two today, so
|
|
293
|
+
copy the shape rather than the component.
|
|
278
294
|
7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
|
|
279
295
|
lookups belong in a module-scope `as const` map.
|
|
280
296
|
8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any HTML is sent.
|
|
@@ -304,7 +320,7 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
|
|
|
304
320
|
rotating log file; nothing supervises it but the orchestrator's probes.
|
|
305
321
|
- **Route prefixes move together or not at all.** `new AkanApp({ prefix, websocketPrefix })` in `main.ts` moves
|
|
306
322
|
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
|
|
323
|
+
`fetchClient` in every tab the server renders. A prebuilt CSR shell or a native app bundle is never rendered by
|
|
308
324
|
a server, so `api: { prefix, websocketPrefix }` in `akan.config.ts` is what those follow; declare it there too
|
|
309
325
|
when the app ships either. Read the value with `getApiPrefix()` / `getWsPrefix()` from `akanjs/base` — never
|
|
310
326
|
write `/api` or `/ws` as a literal, and never try to hand it down as a React prop: `FetchClient` fixes its
|
|
@@ -335,6 +351,14 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
|
|
|
335
351
|
- **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
|
|
336
352
|
trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
|
|
337
353
|
the `akan.config.ts` that owns the font, written against that scope's own `public/`.
|
|
354
|
+
- **Database modes are `database: { modes: [...] }`** — `single` (SQLite + Solid), `multiple` (one SQLite file on
|
|
355
|
+
a host volume + Redis), `cluster` (Postgres + Redis). The build carries the drivers of every declared mode and a
|
|
356
|
+
deployment picks one with `AKAN_DATABASE_MODE` — a declared one, named whenever the build declares several.
|
|
357
|
+
Where the data lives (`POSTGRES_URL`, `REDIS_URI`, `SQLITE_DATABASE_PATH`) is the deployment's env, ahead of
|
|
358
|
+
`env.server.ts`. `q.raw` is written in the dialect's own SQL, so an app running in two modes avoids it.
|
|
359
|
+
- **Backups run through the image's own `main.js`**: `bun main.js ops snapshot` / `ops restore` (offline), and
|
|
360
|
+
`/_akan/ops/*` for a control plane, mounted only when `AKAN_OPS_PUBLIC_KEY` is set and authenticated by a short
|
|
361
|
+
EdDSA token that key verifies. `GET /_akan/app/info` is public. `operationMode` gates none of it.
|
|
338
362
|
|
|
339
363
|
## React Components And Styling (`**/*.tsx`)
|
|
340
364
|
|
|
@@ -357,7 +381,7 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
|
|
|
357
381
|
nothing — they covered five control types, keyed on the translated label, and restored over server data.
|
|
358
382
|
- 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"`).
|
|
359
383
|
- Multi-slot components take extra named props (`wrapperClassName`, `bodyClassName`), never a `classNames` object.
|
|
360
|
-
- Hoist enum→class lookups to a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`. Do not use `Record<...>`.
|
|
384
|
+
- Hoist enum→class lookups to a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`. Do not use `Record<...>`. When a second module needs it, the classes become a variant axis of a recipe in `ui/Recipe/`; only a map from the enum to another recipe's variant name (`BadgeVariants["variant"]`) moves to `webkit/`.
|
|
361
385
|
- Use `<Link>` from `akanjs/ui` for internal navigation; `<a>` only for `mailto:` and external links.
|
|
362
386
|
|
|
363
387
|
## Naming And Language
|
|
@@ -421,8 +445,11 @@ stores need none, because state and CRUD actions are generated. The body is thre
|
|
|
421
445
|
`this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
|
|
422
446
|
`this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
|
|
423
447
|
through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
|
|
424
|
-
nothing** (`no-return-in-store-action.grit`). **
|
|
425
|
-
`
|
|
448
|
+
nothing** (`no-return-in-store-action.grit`). **Reach another store through `this`:**
|
|
449
|
+
`import type { RootStore } from "../st"`, then `(this as unknown as RootStore).logout()` to call its action or
|
|
450
|
+
`(this as unknown as RootStore).set({ … })` / `.get()` to write or read its state. Every store is mixed into one root
|
|
451
|
+
at runtime, so the cast only tells the type what `this` already is. Keep it `import type` — `st.ts` imports every
|
|
452
|
+
store, so a value import is a cycle.
|
|
426
453
|
|
|
427
454
|
**`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
|
|
428
455
|
`.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
|
|
@@ -464,6 +491,25 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
|
|
|
464
491
|
- **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it**
|
|
465
492
|
— a room for a `pubsub` subscribe, the socket for a `message` handler. Cleanup that must happen either way
|
|
466
493
|
registers for both.
|
|
494
|
+
- **A call's deadline is the endpoint's `{ timeout: <ms> }`, and there is nowhere else to put one.** It bounds
|
|
495
|
+
both ends: the `Timeout` middleware — registered by default, and standing aside for every endpoint that
|
|
496
|
+
declares none — rejects the call with `base.error.gatewayTimeout`, and the same value is serialized to the
|
|
497
|
+
client as that call's request budget. Declared nowhere, a call takes the client's own default of **30
|
|
498
|
+
seconds**, so an endpoint whose real work runs longer (provisioning, a firmware flow, an external
|
|
499
|
+
orchestration) has to declare one or it dies mid-flight with a transport error that does not say whether the
|
|
500
|
+
server ever received it. A caller overrides it per call with `fetch.x(…, { timeout })` — `false` waits as long
|
|
501
|
+
as the runtime will — and an app moves its own default with `fetch.instance.setTimeout(ms)`. An upload is
|
|
502
|
+
never bounded. **Losing the race does not cancel the work**: the handler runs to completion with nobody
|
|
503
|
+
holding its result, so a deadline is an answer to the caller, not an undo.
|
|
504
|
+
- **A cached answer is the endpoint's `{ cache: <ms> }`, and only a `query` taking no internal argument may have
|
|
505
|
+
one.** It is a step of the call, not a middleware, and stands aside for every endpoint that declares nothing.
|
|
506
|
+
Internal arguments are how a call learns who is asking (`.with(Self)`), so an endpoint that has them answers
|
|
507
|
+
per caller and one shared entry would be one caller's answer handed to the next — that endpoint and every
|
|
508
|
+
`mutation` are named in the log and left uncached. The lookup runs after the guards, so a hit reaches only a
|
|
509
|
+
caller they admitted; what is cached is the handler's result, so `resolveReturn` still masks fields and resolves
|
|
510
|
+
relations per call; a cache backend that is down is warned about and the call runs uncached. The default
|
|
511
|
+
middleware chain is `Logging → Timeout → <lib middlewares>`, then guards → internal arguments → cache →
|
|
512
|
+
handler, and there is no `Retry` middleware.
|
|
467
513
|
|
|
468
514
|
### Authorization Defaults
|
|
469
515
|
|
|
@@ -530,8 +576,11 @@ Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `la
|
|
|
530
576
|
first "more". `isCumulativeOf<Model>` is the mode.
|
|
531
577
|
- **"Is there more" is `hasMoreOf<Model>`, never `count > page * limit`.** It comes from the length of the batch
|
|
532
578
|
the server returned, so it survives `{ insight: false }` and cannot drift from a count a live event moved.
|
|
533
|
-
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`)
|
|
534
|
-
client fetch results
|
|
579
|
+
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`),
|
|
580
|
+
and so do client fetch results — `FetchClient` parses every return with `crystalize` defaulting to **true**
|
|
581
|
+
(`pkgs/akanjs/fetch/client/fetchClient.ts`). The raw `GetStateObject` shape is what the `<ref>Obj` /
|
|
582
|
+
`<ref>ObjList` / `<ref>ObjInsight` handles carry, because those pass `crystalize: false` on purpose so the
|
|
583
|
+
value can cross the RSC boundary as a client prop.
|
|
535
584
|
- Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
|
|
536
585
|
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
|
|
537
586
|
are query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
|
|
@@ -569,7 +618,7 @@ Full contract — relevance ordering, `columns` / `weights`: `get_guideline` wit
|
|
|
569
618
|
- 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`).
|
|
570
619
|
- 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.
|
|
571
620
|
- 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.
|
|
572
|
-
- **`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.
|
|
621
|
+
- **`memory(...)` takes a scalar or model class, not only a primitive** — `memory(Map, { of: CallStateInput })` serializes through the constant and travels as JSON text, so the Redis and sqlite-backed caches round-trip the same declaration. Never hand-encode JSON into a `String` memory. A memory is scoped to the service or adaptor that declares it; `ttl` (milliseconds) sets how long each write lives unless the `set` call names its own `expireAt`, and a `Map` entry expires on its own.
|
|
573
622
|
|
|
574
623
|
### Adapters — `adapt()` And `plug()`
|
|
575
624
|
|
|
@@ -644,10 +693,14 @@ it.
|
|
|
644
693
|
(RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
|
|
645
694
|
names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
|
|
646
695
|
`aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
|
|
647
|
-
judge it unchanged
|
|
648
|
-
|
|
649
|
-
`
|
|
650
|
-
|
|
696
|
+
judge it unchanged. The tokens this built-in server mints carry no scope; `auth.scopes` / `AKAN_MCP_SCOPES` are for
|
|
697
|
+
tokens an external issuer mints, and declared beside this server they refuse every one of its tokens with 403
|
|
698
|
+
`insufficient_scope`. Per-app knobs live in `env.server.*` under `oauth` (`consentPath` and
|
|
699
|
+
`signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `accessTokenSeconds`,
|
|
700
|
+
`clientIdMetadata`, `enabled`, plus `issuer` / `resource` — set those when a tunnel or an edge makes the derived
|
|
701
|
+
origin wrong, because an MCP client compares the issuer byte for byte). `/mcp` reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` (or
|
|
702
|
+
`security.jwtSecret`) is mandatory whenever `operationMode` is not `local`, because the derived fallback is
|
|
703
|
+
forgeable from three public names; `AKAN_ALLOW_DERIVED_JWT_SECRET=1` accepts that risk explicitly. A grant is revoked
|
|
651
704
|
whole — `POST /oauth/revoke` (RFC 7009) by the client, `fetch.revokeOAuthConnection(sessionId)` by the account's
|
|
652
705
|
owner, `fetch.listOAuthConnections()` to see them — and a revoked access token is refused at its next call.
|
|
653
706
|
- **A person may, a model may not.** An agent's token names its `client_id` and `aud`; a browser session has
|
|
@@ -696,9 +749,12 @@ what every developer must know even when not building one.
|
|
|
696
749
|
every tool runs in the caller's own browser session, gated by guards and the approval card. Its guard is
|
|
697
750
|
`AgentRelayAccess`, which refuses every call until the app names a guard of its own —
|
|
698
751
|
`option.setAgentAccess(SignedIn)`. **The LLM is configured in `option.ts`, never through the environment**
|
|
699
|
-
(`option.setLlm({ apiKey, model, host })`).
|
|
700
|
-
|
|
701
|
-
`
|
|
752
|
+
(`option.setLlm({ apiKey, model, host })`). Two adaptors ship, one per wire: `OpenaiLlm` is the default and
|
|
753
|
+
speaks the chat-completions dialect to whatever `host` names — OpenAI, DeepSeek, Groq, OpenRouter, Ollama — and
|
|
754
|
+
`AnthropicLlm` speaks the Messages API and reads a PDF as well as a picture. Both require `model`. A provider
|
|
755
|
+
neither covers is an `adapt()` class in `srvkit/` implementing `LlmAdaptor`, applied with
|
|
756
|
+
`option.applyAdaptor(LlmAdaptorRole, TheClass)`; whatever settings it needs beyond `LlmOption` ride `setLlm`
|
|
757
|
+
too, read back with `use<MyLlmOption>()`.
|
|
702
758
|
- **Declare the tool beside the control that already does it**, never as a separate surface:
|
|
703
759
|
`st.tool("x").desc("…").arg("id", ID).exec(fn)` returns the callable to hand to `onClick` — one handler for the
|
|
704
760
|
person and the agent. Publish it only where the screen already renders the control; a falsy name declares the
|
|
@@ -727,6 +783,7 @@ what every developer must know even when not building one.
|
|
|
727
783
|
- Put display and predicate logic on the `Light<Model>` class rather than in a util module — see Module File Playbook.
|
|
728
784
|
- Defaults are a literal for scalars and a thunk for anything constructed. Arrays are `field([T])`; optional is the postfix `.optional()`.
|
|
729
785
|
- **`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.
|
|
786
|
+
- **A `field.hidden` / `field.secret` value reads `null` on the client, and the type still declares it.** `SignalContext.resolveReturn` skips both field types while it builds an endpoint's response, and hydration writes `null` over the key rather than leaving it absent — the first branch of the loop in `constant/getDefault.ts`, ahead of the field's own default. So the value is a deliberate `null` behind a type promising a `string`, every use of it typechecks, and the failure lands wherever it is finally dereferenced instead of where it was read. **Guard with `??` or `== null`**: `=== undefined`, a destructuring default and an optional parameter default all catch only a missing key and sail straight past this one. A projection (`pickById(id, { secret: true })`) widens the *server's* read, never the response. If a screen needs the value, the field is neither hidden nor secret; if it only needs to be cheap for a model rather than unseen, that is `field.visual`.
|
|
730
787
|
|
|
731
788
|
### Scalar & Field Type Reference
|
|
732
789
|
|
|
@@ -760,7 +817,8 @@ Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH
|
|
|
760
817
|
backstop, including for a `text` field *underneath* one of those. Do not work around either.
|
|
761
818
|
- The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
|
|
762
819
|
indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
|
|
763
|
-
- Search runs
|
|
820
|
+
- Search runs in every database mode — fts5 on SQLite/libSQL, a weighted `tsvector` (`pg_trgm` for `trigram`) on
|
|
821
|
+
Postgres. The same text matches the same documents; only the order may differ on Postgres.
|
|
764
822
|
|
|
765
823
|
### Image & File Fields
|
|
766
824
|
|
|
@@ -809,10 +867,11 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
809
867
|
- Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
|
|
810
868
|
- **A route file exports one thing**: `export default page()…render(…)` for a page, `layout()…render(…)` for a
|
|
811
869
|
`_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting
|
|
812
|
-
is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()
|
|
813
|
-
`.
|
|
814
|
-
|
|
815
|
-
|
|
870
|
+
is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()`, `.loading()`; `.notFound()` /
|
|
871
|
+
`.error()` on a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()` /
|
|
872
|
+
`.layoutStyle()` on the root layout; `.prompt()` on a page. A named export beside the chain, or `page()` in a
|
|
873
|
+
`_layout.tsx`, fails the build. `.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function of the
|
|
874
|
+
route's args returning it; there is no metadata object, and analytics is the app's own script, not a stage.
|
|
816
875
|
- **A page names every `[x]` segment of its path with `.param("x", Type)`** and reads a query key only through
|
|
817
876
|
`.search("k", Type)`. Values arrive typed — `ID`/`String` → string, `Int`/`Float` → number, `Boolean`, `Date` →
|
|
818
877
|
Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
|
|
@@ -855,10 +914,10 @@ export default page()
|
|
|
855
914
|
|
|
856
915
|
## Akan Sync Conventions (`apps/**`, `libs/**`)
|
|
857
916
|
|
|
858
|
-
- `apps/<appName>` root may only contain these files: `AGENTS.md`, `CLAUDE.md`, `akan.app.json`, `akan.config.ts`, `
|
|
859
|
-
- `apps/<appName>` root may only contain these folders: `.akan`, `
|
|
917
|
+
- `apps/<appName>` root may only contain these files: `AGENTS.md`, `CLAUDE.md`, `akan.app.json`, `akan.config.ts`, `client.ts`, `main.ts`, `package.json`, `server.ts`, `tsconfig.json`, `tsconfig.tsbuildinfo`.
|
|
918
|
+
- `apps/<appName>` root may only contain these folders: `.akan`, `common`, `env`, `lib`, `page`, `plugin`, `private`, `public`, `script`, `secrets`, `srvkit`, `ui`, `webkit`.
|
|
860
919
|
- `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`.
|
|
861
|
-
- `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`,
|
|
920
|
+
- `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`, `.akan`, `script`, `secrets`) are rejected there. A Capacitor-era `ios` / `android` / `mobile` folder or `capacitor.config.*` is refused in an app root too, named as a leftover: the native runtime generates its projects under `.akan/mobile/<target>`.
|
|
862
921
|
- Both allowlists have one source — `pkgs/@akanjs/devkit/workspaceLayout.ts`. `akan sync` (error), `akan doctor`
|
|
863
922
|
(diagnostic), and `akan quality scan` (warning) all read it, so add a new root entry there and mirror it into this
|
|
864
923
|
list, never into one of the three call sites.
|
|
@@ -882,12 +941,15 @@ export default page()
|
|
|
882
941
|
| Folder | Admission test | Naming |
|
|
883
942
|
|---|---|---|
|
|
884
943
|
| `common/` | pure, isomorphic, zero-dependency; may import only sibling `common/*` and `akanjs/base`. Cannot import `Err`, so keep throwing code out of it. | camelCase file, filename equals the single export |
|
|
885
|
-
| `webkit/` | touches `window` / `navigator` /
|
|
944
|
+
| `webkit/` | touches `window` / `navigator` / the native bridge (`akanjs/client/native`), or is a React hook | `use<Thing>.tsx` — `.tsx` even with no JSX |
|
|
886
945
|
| `srvkit/` | touches `node:*`, `Bun`, `process.env`, a secret, or a server SDK | camelCase file, PascalCase class |
|
|
887
|
-
| `ui/` | renders JSX and is not bound to one model | PascalCase component, camelCase sidecar (`swipeCard.util.ts`) |
|
|
946
|
+
| `ui/` | renders JSX, or defines a look (a recipe in `Recipe/`, a lib's `tokens.css`), and is not bound to one model | PascalCase component, camelCase sidecar (`swipeCard.util.ts`), `Recipe/<name>.ts` |
|
|
888
947
|
| `plugin/` | build- or CLI-time `AkanPlugin` | `<name>.plugin.ts`, registered in `akan.config.ts` |
|
|
889
948
|
|
|
890
949
|
- Hooks return a named object of async closures, never a tuple.
|
|
950
|
+
- `ui/` is the presentation layer — markup and the looks it is built from — so a recipe lives there although it is
|
|
951
|
+
neither a component nor a hook. Not `webkit/`: its hooks are `"use client"`, the opposite signal for a function
|
|
952
|
+
server components call. Not `common/`: it cannot import `akanjs/ui`, where `recipe` / `tv` come from.
|
|
891
953
|
- `libs/<lib>/ui/tokens.css` is the one CSS file a lib owns: plain `:root` custom properties for colors that must **not** follow the theme (a vendor brand color, a fixed surface). Every app whose pages reach that lib compiles it automatically, ahead of the app's own stylesheets, so nothing is imported by hand and no app can forget it. Reference them as `bg-[var(--kakao)]`; `@theme` extensions stay in the app stylesheet, because the color vocabulary is closed per stylesheet. Theme-following colors are the app's, not the lib's.
|
|
892
954
|
- A layer-root `index.ts` is generated, but a `ui/<Folder>/index.tsx` that builds a namespace is hand-written source. The distinguishing test is that a generated barrel contains nothing but `export * from "./X";` lines.
|
|
893
955
|
- `ui/<Folder>/index_.tsx` (trailing underscore) is the `"use client"` + `lazy()` boundary, with a server-safe `index.tsx` beside it. Collapsing the pair into one file breaks RSC.
|
|
@@ -900,12 +962,15 @@ when two shapes disagree.
|
|
|
900
962
|
- `children: any` — newer files use `children: ReactNode`.
|
|
901
963
|
- Hard-coded API keys or secrets in source; they belong in `option.ts` or env.
|
|
902
964
|
- Large blocks of commented-out code left in place.
|
|
903
|
-
- Cross-store writes through a `RootStore` cast — it collides with the Bun SSR-bundler segfault.
|
|
904
965
|
- Stale `// TODO: Implement …` comments above implemented methods.
|
|
905
966
|
- `{cond && <X/>}` in JSX, hard-coded Korean bypassing `l()`, and `window.alert(...)` for user feedback.
|
|
906
967
|
- Bare `/* eslint-disable */` blocks — use `// biome-ignore lint/<rule>: <why>`.
|
|
907
968
|
- Raw palette grays such as `text-gray-400` instead of the semantic tokens (`text-foreground/70`).
|
|
908
969
|
|
|
970
|
+
A store calling `(this as unknown as RootStore).<action>()` or `.set({ … })` over
|
|
971
|
+
`import type { RootStore } from "../st"` is **not** one of these — it is the sanctioned way to reach another store's
|
|
972
|
+
actions and state (see `<model>.store.ts` above).
|
|
973
|
+
|
|
909
974
|
## Secrets And Env Safety (`.env`, `infra/**`, `*secret*`, `*credential*`)
|
|
910
975
|
|
|
911
976
|
- Never print, summarize, commit, or expose real secret values, credentials, tokens, private keys, or `.env` contents.
|
|
@@ -924,7 +989,9 @@ when two shapes disagree.
|
|
|
924
989
|
selected one's log beside it. `Tab` walks every row and `1`-`9` jump to that app (the rail shows each
|
|
925
990
|
app's digit), `↑↓` scroll (shift for a page, `G` back to following),
|
|
926
991
|
`/` greps, `e` shows stderr only, `c` clears, `y` copies, `Y` copies the log path, `o` opens the browser,
|
|
927
|
-
`r` restarts that app, `q` quits.
|
|
992
|
+
`r` restarts that app, `q` quits. Under `--share`, `s` copies the public URL — the selected app's, or every
|
|
993
|
+
app's from the merged row — and the header carries it ahead of the local one, because the line `--share`
|
|
994
|
+
printed before the view took the terminal is gone by its first repaint.
|
|
928
995
|
**`--plain` prints prefixed interleaved lines instead**, and a pipe, a redirect or an unsized terminal
|
|
929
996
|
downgrades to that on its own.
|
|
930
997
|
- **A supervised session writes `local/apps/<app>/runtime/dev.log`, and that path is how a log gets handed over.**
|
|
@@ -9,6 +9,7 @@ Use this whenever generating constants or scalar constants. It defines the curre
|
|
|
9
9
|
- `field(Type).optional()` marks a nullable or optional business value when absence is valid.
|
|
10
10
|
- `field.hidden(Type).optional()` is for internal state that should not be treated as a normal public field.
|
|
11
11
|
- `field.secret(Type).optional()` is for sensitive values that should not be selected by default.
|
|
12
|
+
- Neither survives an endpoint response: both are stripped on the way out and hydration writes `null` over the key, while the generated type still declares the field — so a client holds a deliberate `null` behind a type that promises a value, and only `??` / `== null` catch it. A field a browser has to read is neither.
|
|
12
13
|
- `field([Type])` defines arrays; provide empty array defaults when the app expects list operations.
|
|
13
14
|
|
|
14
15
|
## Current Akan Patterns
|
|
@@ -189,11 +189,13 @@ export default page()
|
|
|
189
189
|
- OAuth resource metadata is published at `/.well-known/oauth-protected-resource` (and at that path plus the mount
|
|
190
190
|
path, the spelling most clients try first). `auth.authorizationServers` / `AKAN_MCP_AUTH_SERVERS`,
|
|
191
191
|
`auth.scopes` / `AKAN_MCP_SCOPES`, `auth.resource` / `AKAN_MCP_RESOURCE` and `auth.verify` configure it;
|
|
192
|
-
`insufficient_scope` is enforced only once scopes are declared.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
issuer
|
|
192
|
+
`insufficient_scope` is enforced only once scopes are declared. Declare them only for tokens an external issuer
|
|
193
|
+
mints: the built-in authorization server's tokens carry no scope, so beside it every one of them would be refused
|
|
194
|
+
with 403. Naming an issuer changes three things at once: a request with no credential is answered 401 from
|
|
195
|
+
`initialize` on (an MCP client starts its OAuth flow on nothing else), a token carrying no `aud` is refused (that
|
|
196
|
+
issuer mints for its other resources too), and the challenge carries no `error` code (RFC 6750 §3.1 reserves it
|
|
197
|
+
for a credential that was presented). A server naming no issuer keeps anonymous access and accepts a first-party
|
|
198
|
+
token, which is bound by app and environment instead.
|
|
197
199
|
- **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
|
|
198
200
|
description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
|
|
199
201
|
the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
|
|
@@ -49,7 +49,7 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
49
49
|
`findBy<Filter>(...args, { select: { secretField: true } })` takes it inside the option object. The shapes do not
|
|
50
50
|
swap: `{ select: … }` handed to the facade projects a field named `select`, which no model has, and the read comes
|
|
51
51
|
back empty with no error. This is the only way to read a `field.secret(...)` value, which is otherwise stripped.
|
|
52
|
-
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`)
|
|
52
|
+
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`), and so do client fetch results — `FetchClient` parses every return with `crystalize` defaulting to **true** (`pkgs/akanjs/fetch/client/fetchClient.ts`). The raw `GetStateObject` shape (functions stripped, `pkgs/akanjs/base/types.ts`) is what the `<ref>Obj` / `<ref>ObjList` / `<ref>ObjInsight` handles carry, because those pass `crystalize: false` on purpose so the value can cross the RSC boundary as a client prop.
|
|
53
53
|
- Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
|
|
54
54
|
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four are
|
|
55
55
|
query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use them on a
|
|
@@ -90,14 +90,15 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
90
90
|
- **A slice endpoint never reaches "unspecified".** The resolver fills `latest` before the query is built, so a
|
|
91
91
|
client asking for the score order has to name `relevance`; leaving `sort` off gets `latest`, not relevance.
|
|
92
92
|
- Scope a search with `columns` (`q.search(text, { columns: ["title"] })`) and re-weight with `weights`, a tuple of
|
|
93
|
-
finite numbers positional over `["title", "desc", "tag", "filter"]
|
|
93
|
+
finite, non-negative numbers positional over `["title", "desc", "tag", "filter"]` — Postgres ranks by weight
|
|
94
|
+
class and has no reading for a negative one.
|
|
94
95
|
|
|
95
96
|
|
|
96
97
|
## Text Search Fields — the `text` role
|
|
97
98
|
|
|
98
99
|
- A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and likewise
|
|
99
100
|
`"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model switch.
|
|
100
|
-
- Pick the role by what the value *is*, because
|
|
101
|
+
- Pick the role by what the value *is*, because the rank weights them positionally (`title` 10, `tag` 3, `desc` 1,
|
|
101
102
|
`filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
|
|
102
103
|
scoping value (status, owner, role) that must be matchable but must never outrank a real title hit.
|
|
103
104
|
- `thumb` is mirrored for rendering a hit and is **not** indexed — never expect it to match.
|
|
@@ -114,15 +115,24 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
114
115
|
- Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
|
|
115
116
|
because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
|
|
116
117
|
path.
|
|
117
|
-
- Search runs on
|
|
118
|
+
- Search runs in every database mode: fts5 on SQLite/libSQL, a weighted `tsvector` under a GIN index on Postgres.
|
|
119
|
+
For the same text the same documents match — punctuation splits words the way unicode61 does, and a phrase never
|
|
120
|
+
runs from one column into the next — but the order may differ, since Postgres ranks without document frequency.
|
|
121
|
+
Assert on the match set, never on a Postgres order the weights alone do not decide.
|
|
122
|
+
- Postgres reads `AKAN_SEARCH_TOKENIZER` as `unicode61 [remove_diacritics 0|1|2]` (`simple`, with `unaccent` unless
|
|
123
|
+
`0`) or `trigram [case_sensitive 0|1]` (`pg_trgm`); any other spelling is refused. The extension is created when
|
|
124
|
+
the role may, otherwise a role that may runs `CREATE EXTENSION`. It indexes the first 20,000 characters of
|
|
125
|
+
`title`, `tag` and `filter` and 200,000 of `desc` — a tsvector refuses more than 1MB, and the refusal would fail
|
|
126
|
+
the model write. A database whose `LC_CTYPE` is `C` keeps non-ASCII case apart; use a UTF-8 ctype.
|
|
118
127
|
- `AKAN_SEARCH_ENABLED=0` switches the index off process-wide; unset means on. It never deletes mirror data, and
|
|
119
128
|
re-enabling reconciles every ref. **Give every process the same value** — a process cannot drop triggers for models
|
|
120
129
|
it does not mount, so a mixed fleet leaves stale triggers behind.
|
|
121
130
|
- The tokenizer is `AKAN_SEARCH_TOKENIZER` (or `database.search.tokenizer`, which wins), defaulting to
|
|
122
131
|
`unicode61 remove_diacritics 2`. Changing it rebuilds the index from the mirror on the next boot — the model
|
|
123
|
-
tables are never re-read — so it is a safe knob, unlike a `text` role change, which re-reads every row. The
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
tables are never re-read — so it is a safe knob, unlike a `text` role change, which re-reads every row. The schema
|
|
133
|
+
is ensured in one write turn — a SQLite write transaction, a Postgres schema lock — so of a fleet restarted at
|
|
134
|
+
once the first rebuilds and the rest find it current. A SQLite process waits for that turn up to its
|
|
135
|
+
`busy_timeout`, so stagger the restart when the mirror is large.
|
|
126
136
|
|
|
127
137
|
## Cascade Remove — the `cascade` option
|
|
128
138
|
|
|
@@ -16,9 +16,9 @@ const config: AppConfig = { web: false }; // api only
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
- **`web: { csr: false }`** drops the CSR build phase and the `/__csr` + `?csr=true` routes. The CSR bundle is
|
|
19
|
-
what the
|
|
19
|
+
what the native mobile build ships, so a web-only deployment never needs it — and an app that declares a
|
|
20
20
|
`mobile` section is refused, because `akan build-ios` copies `dist/apps/<app>/csr/<target>.html` into the
|
|
21
|
-
|
|
21
|
+
target's web root, `.akan/mobile/<target>/web`.
|
|
22
22
|
- **`web: false`** is an API-only build: no base artifact, no pages or client bundles, no RSC worker
|
|
23
23
|
entrypoint, and no `public/` in the image (the web router's catch-all is its only reader). Nothing under
|
|
24
24
|
`page/` is served, including routes a lib contributed through `syncPageLibs`.
|
|
@@ -68,7 +68,7 @@ const config: AppConfig = { api: { prefix: "/backend", websocketPrefix: "/socket
|
|
|
68
68
|
origin when the module graph initializes, before any component renders, so a React prop — a `System.Provider`
|
|
69
69
|
value, anything out of `implicitRootLayout` — is always too late. The value rides the SSR classic bootstrap
|
|
70
70
|
script instead, the one thing guaranteed to run ahead of every module script, and is omitted when it matches
|
|
71
|
-
what the bundle already assumes. `akan.config.ts` is what a
|
|
71
|
+
what the bundle already assumes. `akan.config.ts` is what a native app bundle or a statically served CSR shell
|
|
72
72
|
follows, because neither is rendered by a server that could tell it otherwise.
|
|
73
73
|
- **A prefix is a path segment.** A blank value or a bare `/` is refused: `/` would be mounted ahead of the SSR
|
|
74
74
|
catch-all and swallow every page route. A prefix whose first segment is a declared basePath is refused at
|
|
@@ -184,6 +184,80 @@ const config: AppConfig = { docker: "FROM oven/bun:1-slim\n…" }; // verbatim,
|
|
|
184
184
|
- `AkanAppConfig.docker` is the resolved declaration; `AkanAppConfig.dockerfile` is the text `akan build` writes
|
|
185
185
|
to `dist/apps/<app>/Dockerfile`.
|
|
186
186
|
|
|
187
|
+
## Database Modes — `database` In `akan.config.ts`
|
|
188
|
+
|
|
189
|
+
| mode | database | cache · queue · pubsub | runs as |
|
|
190
|
+
|---|---|---|---|
|
|
191
|
+
| `single` | SQLite file | SQLite files (Solid) | one container |
|
|
192
|
+
| `multiple` | one SQLite file (WAL) on a host volume every container on that host opens | Redis | containers on one host |
|
|
193
|
+
| `cluster` | Postgres | Redis | pods on several servers |
|
|
194
|
+
|
|
195
|
+
- **`database: { modes: ["single", "cluster"] }` is what the build can run in**; the first is what `akan start`
|
|
196
|
+
runs. `akan build` carries the drivers of every declared mode (`multiple` → `bullmq`, `ioredis`; `cluster` adds
|
|
197
|
+
`postgres`).
|
|
198
|
+
- **A deployment names its mode with `AKAN_DATABASE_MODE`**, and only a declared one. With one declared mode it may
|
|
199
|
+
leave it out; with several it must name one — the fallback would be a SQLite file inside each pod. `akan start`,
|
|
200
|
+
`build`, `script` and `console` resolve it the same way from the shell. One image can serve an edge site in
|
|
201
|
+
`single` and a cloud cluster in `cluster`: declare both.
|
|
202
|
+
- **Where the data lives is the deployment's env, ahead of `env.server.ts`**: `SQLITE_DATABASE_PATH`,
|
|
203
|
+
`AKAN_SOLID_DB_PATH`, `POSTGRES_URL` (or `POSTGRES_HOST`/`PORT`/`DATABASE`/`USER`/`PASSWORD`),
|
|
204
|
+
`POSTGRES_INSIGHT_URL`, `LIBSQL_URL`. `REDIS_URI` is required outside local development. Pool size, SSL and
|
|
205
|
+
prepared statements ride the Postgres URL (`?max=20&ssl=require`, `prepare=false` behind PgBouncer).
|
|
206
|
+
- **Uploads**: a deployed `multiple`/`cluster` app uses object storage, or a volume every instance mounts with
|
|
207
|
+
`AKAN_STORAGE_SHARED=true`.
|
|
208
|
+
- **`runAdminSql` on Postgres reads as its own login role** holding base-column `SELECT` and nothing else:
|
|
209
|
+
`CREATE ROLE <name> LOGIN PASSWORD '…'` with no other role granted, `POSTGRES_INSIGHT_URL` logging in as it, and
|
|
210
|
+
`GRANT USAGE ON SCHEMA` from the DBA when the app does not own its schema. Every model table grants the role its
|
|
211
|
+
base columns at boot. Without it the console refuses on Postgres; `_doc` is never readable either way.
|
|
212
|
+
- **`akan dbup`** starts what the workspace's apps declare (`--mode multiple` is Redis, `cluster` adds Postgres).
|
|
213
|
+
**`akan db-export <app>` / `akan db-import <app>`** move every model table as stored rows through NDJSON files
|
|
214
|
+
(`--dir`, default `local/transfer`) between whatever the shell's `AKAN_DATABASE_MODE` points at; a rerun
|
|
215
|
+
replaces rows, search is rebuilt after, and sessions, queued jobs and uploaded files do not move.
|
|
216
|
+
- **`q.raw(sql)` is the dialect's own SQL** — `json_extract("_doc", '$.f')` on SQLite, `("_doc" #>> '{f}')` on
|
|
217
|
+
Postgres. An app that runs in both modes avoids it.
|
|
218
|
+
|
|
219
|
+
## Operations — Snapshots, Restore And The `/_akan/ops` Channel
|
|
220
|
+
|
|
221
|
+
`GET /_akan/app/info` answers `{ appName, repoName, environment, operationMode }` on every deployment — the same
|
|
222
|
+
`AKAN_PUBLIC_*` values the client bundle already carries — so a control plane can check which app a host is
|
|
223
|
+
serving without a key. Everything else is behind the ops channel.
|
|
224
|
+
|
|
225
|
+
| surface | what it does |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `bun main.js ops snapshot [--id] [--out] [--include-solid] [--json]` | `VACUUM INTO` a consistent copy, `integrity_check`, sha256, gzip, optional age encryption, `manifest.json` |
|
|
228
|
+
| `bun main.js ops restore <manifest.json> [--identity <file>]` | offline only: verify, stage, keep the old file as `<db>.pre-restore-<ts>` (its `-wal`/`-shm` beside it), swap |
|
|
229
|
+
| `GET /_akan/ops/info` | the public info plus `akanVersion`, `buildId`, `serverMode`, `databaseMode`, `solo`, `startedAt`, `replicaIdx` |
|
|
230
|
+
| `POST /_akan/ops/snapshot` `{ id, includeSolid?, uploadUrls: { main, solid?, manifest } }` | 202; a child process snapshots, then PUTs each file and the manifest **last** |
|
|
231
|
+
| `GET /_akan/ops/snapshot/:id` | `running` → `uploading` → `done` \| `failed`; kept in memory, so a restart answers 404 |
|
|
232
|
+
|
|
233
|
+
- **The entry is the image's own `main.js`** — a production image carries no `akan` CLI. `AkanApp.start()` hands
|
|
234
|
+
`argv[2] === "ops"` to the command before it boots anything. It reads the container's env — the `AKAN_PUBLIC_*`
|
|
235
|
+
identity and the database paths — so run it with that env (`docker compose exec`), never a bare shell's.
|
|
236
|
+
- **Restore refuses while an app answers `/_akan/app/health` on `PORT`** and names it by its `/_akan/app/info`;
|
|
237
|
+
`--force` is for a port some other app holds.
|
|
238
|
+
- **The channel exists only when `AKAN_OPS_PUBLIC_KEY` is set** (Ed25519: PEM, JWK, base64 SPKI or raw 32 bytes).
|
|
239
|
+
The control plane signs a short EdDSA JWS — `aud`, `iat`, `exp` at most 300s after `iat`, a `jti` the process
|
|
240
|
+
refuses twice — and the host holds only the public half, so a machine someone carries off mints nothing.
|
|
241
|
+
`aud` is `<appName>/<environment>`, or `<appName>/<environment>/<AKAN_OPS_INSTANCE>` when that is set, and then
|
|
242
|
+
only that: a token minted for the app as a whole would replay across every host sharing the name. 30 calls a
|
|
243
|
+
minute per process; every refusal is the same 401. A malformed key leaves the app serving with the channel off.
|
|
244
|
+
- **The app holds no storage credential.** It PUTs to the presigned URLs it was handed (`application/octet-stream`,
|
|
245
|
+
the manifest `application/json`), plain http only to loopback. The manifest landing is the commit marker.
|
|
246
|
+
- **Snapshots cover the SQLite modes only.** `single` copies the main file and, with `includeSolid`, the Solid file;
|
|
247
|
+
`multiple` copies the main file; `cluster` is refused. Paths follow the adaptors' env precedence
|
|
248
|
+
(`SQLITE_DATABASE_PATH`, `AKAN_SOLID_DB_PATH`, `AKAN_SQLITE_DIR`); a solo process uses the path its adaptor
|
|
249
|
+
actually opened, so a gateway deployment that moved the file in `env.server.ts` names it in the env too.
|
|
250
|
+
- **`AKAN_BACKUP_RECIPIENT=age1…[,age1…]` encrypts before upload** — age v1 X25519, so `age -d -i key.txt` opens
|
|
251
|
+
it. Restore takes `--identity` / `AKAN_BACKUP_IDENTITY_FILE`, or a file an operator already decrypted with
|
|
252
|
+
`age -d -o <name without .age>`.
|
|
253
|
+
- **The host keeps the newest `AKAN_OPS_SNAPSHOT_KEEP` (default 2) snapshots** under `AKAN_OPS_SNAPSHOT_DIR`
|
|
254
|
+
(default `<db dir>/snapshots/<id>/`) — the copy an edge still has while its uplink or the bucket is down.
|
|
255
|
+
- **`buildId` is `AKAN_BUILD_ID` when the deployment sets it**, else the git sha `akan build` wrote into
|
|
256
|
+
`akan.build.json` beside `main.js` (`-dirty` when the tree was).
|
|
257
|
+
- **`operationMode` does not gate any of this** — the key does. `edge` means a production host outside the
|
|
258
|
+
cluster: the console refuses it like `cloud`, and `init` / `interval` / `cron` take `operationMode: ["edge"]` to
|
|
259
|
+
run only there. Everything else behaves as `cloud`; `local` is a developer machine.
|
|
260
|
+
|
|
187
261
|
## Shipped Assets — `assets` In `akan.config.ts`
|
|
188
262
|
|
|
189
263
|
`akan build` copies the whole `public/` tree into `dist`, lib assets dereferenced, and that copy is the image.
|
|
@@ -129,8 +129,10 @@ eight queries threw; per-section boundaries put each rejection at its own bounda
|
|
|
129
129
|
soft-redirect script, so `getSelf({ unauthorize: "/signin" })` stays in `_layout.tsx` or the page body — never
|
|
130
130
|
inside a streamed section.
|
|
131
131
|
|
|
132
|
-
**⑥ Use named `ReactNode` slots, not just `children`.**
|
|
133
|
-
|
|
132
|
+
**⑥ Use named `ReactNode` slots, not just `children`.** A client shell that takes one slot per region lets a
|
|
133
|
+
server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
|
|
134
|
+
declares `title` / `left` / `right` alongside `back` and `children`, but its render body uses only `back` and
|
|
135
|
+
`children` — copy the shape, not that component.
|
|
134
136
|
|
|
135
137
|
**⑦ Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>` (`isNew()`,
|
|
136
138
|
`canWrite(user?)`, `formatTimes()`), and enum→class lookups belong in a module-scope `as const` map. Both sides call
|
|
@@ -28,6 +28,57 @@ mutation is mounted under. `conventions` carries the invariants — this is the
|
|
|
28
28
|
neither again, and cleanup that must happen either way registers for both. From a `message` handler it belongs to
|
|
29
29
|
the socket and runs at close. A handler that throws is logged and never blocks the rest of the teardown.
|
|
30
30
|
|
|
31
|
+
## Request Deadlines
|
|
32
|
+
|
|
33
|
+
- **`{ timeout: <ms> }` in an endpoint's signal option is the whole configuration surface**, and it reaches both
|
|
34
|
+
ends of the call. Server side, the `Timeout` middleware races the handler against it and answers
|
|
35
|
+
`base.error.gatewayTimeout` (504) once it is spent. Client side, `FetchSerializer` ships the value on the
|
|
36
|
+
serialized endpoint and `fetch.<endpoint>()` uses it as that request's `AbortSignal.timeout` budget, so the
|
|
37
|
+
browser does not abandon a call the server is still allowed to be working on.
|
|
38
|
+
- **The middleware is registered by default and has no default of its own.** An endpoint that declares no
|
|
39
|
+
`timeout` is passed straight through — a default here would put a deadline on every endpoint in the app that
|
|
40
|
+
nobody asked for, which is why the middleware used to be opt-in and, being opt-in, made the declared value
|
|
41
|
+
dead. It clears its timer in a `finally`: the race's loser is a pending timer per call otherwise, and with a
|
|
42
|
+
five-minute budget that is a five-minute hold on the event loop for a call that answered in a millisecond.
|
|
43
|
+
- **Without a declaration the client's own default applies — 30 seconds** (`DEFAULT_TIMEOUT_MS`,
|
|
44
|
+
`fetch/client/httpClient.ts`), and the server imposes nothing. Nothing else bounds a request: `Bun.serve` runs
|
|
45
|
+
with `idleTimeout: 0`, so a long call is only ever ended by one of these two.
|
|
46
|
+
- **Precedence is caller, then endpoint, then client default.** `fetch.x(…, { timeout: 5_000 })` overrides what
|
|
47
|
+
the endpoint declared; `{ timeout: false }` waits as long as the runtime will. An app moves its own default
|
|
48
|
+
with `fetch.instance.setTimeout(ms)` — `lib/useClient.ts` is generated, so the `timeout` option on
|
|
49
|
+
`FetchClient.build` belongs to the template that writes it, not to app code.
|
|
50
|
+
- **A file upload is never bounded**, on any of those paths: a large body on a slow uplink is a request that is
|
|
51
|
+
working, and the request body is the only thing the client can tell that from.
|
|
52
|
+
- **The deadline answers the caller; it does not undo the call.** `Promise.race` cannot cancel `next()`, so a
|
|
53
|
+
handler that timed out keeps running and keeps writing. Reach for a `timeout` to bound what a *caller* waits
|
|
54
|
+
for, not to bound an effect — an operation that must not half-happen needs its own idempotency or compensation.
|
|
55
|
+
- The failure is the same `base.error.gatewayTimeout` key whichever side gave up, so the user reads one sentence
|
|
56
|
+
in their own language either way. What differs is the status code — 504 from the server's middleware, 408 from
|
|
57
|
+
the client's own abort — and nothing downstream branches on that.
|
|
58
|
+
|
|
59
|
+
## Cached Answers
|
|
60
|
+
|
|
61
|
+
- **`{ cache: <ms> }` in an endpoint's signal option is the whole cache surface.** The lookup is a step of every
|
|
62
|
+
call and stands aside unless an endpoint declares one.
|
|
63
|
+
- **Only a `query` that takes no internal argument may carry one.** The handler's inputs are its declared
|
|
64
|
+
arguments plus its internal ones, so an endpoint with none of the latter answers the same thing to everyone who
|
|
65
|
+
may read it — which is the only answer a shared entry can hold. An endpoint that takes `.with(Self)` answers
|
|
66
|
+
per caller, and one entry would be one caller's answer handed to the next; that endpoint and every `mutation`
|
|
67
|
+
are named once in the log and left uncached rather than silently ignored.
|
|
68
|
+
- **The guards run before every hit.** The lookup sits after the guards and the internal arguments, inside the
|
|
69
|
+
call, so every middleware — the account resolution a lib registers among them — has already run and a hit
|
|
70
|
+
reaches only a caller the guards admitted. It used to be a middleware ahead of the lib's, and a guard reading
|
|
71
|
+
the account refused a signed-in caller on every hit. Shared is not public.
|
|
72
|
+
- **The entry is the handler's result, not the response.** The lookup wraps the handler only, so `resolveReturn`
|
|
73
|
+
still runs per call on the cached value — field masking, `hidden`/`secret` stripping and relation resolution are
|
|
74
|
+
never cached, and a relation still costs its own load. The cache saves the handler, not the serialization.
|
|
75
|
+
- **A cache backend that is down does not take the endpoint down.** A failed read is warned and the call runs
|
|
76
|
+
uncached; a failed write is warned and dropped.
|
|
77
|
+
- The default middleware chain is `Logging → Timeout → <lib middlewares>`, and the call inside it runs guards →
|
|
78
|
+
internal arguments → cache → handler: a hung cache backend is bounded by the same deadline as the handler it
|
|
79
|
+
stands in for. **`Retry` was removed** — an automatic re-run of an endpoint
|
|
80
|
+
whose failure it cannot classify replays whatever the first attempt already did.
|
|
81
|
+
|
|
31
82
|
## Binary Pubsub
|
|
32
83
|
|
|
33
84
|
- **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON `{ type: "pub" }`
|