@akanjs/cli 3.1.0-rc.0 → 3.1.0-rc.10
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-q7a4srt6.js → CodeAgent-rkjyxgec.js} +2 -2
- package/{CodeTui-ak23dxc9.js → CodeTui-h7pyj6xa.js} +1 -1
- package/TunnelShare-661cxz5e.js +14 -0
- package/{agent.command-f3gsc1gd.js → agent.command-3qxf4xcj.js} +5 -5
- package/akanCode.js +2 -2
- package/{application.command-3v2d53gd.js → application.command-m1kcd7jf.js} +54 -42
- package/{applicationBuildRunner-1yaqsrdq.js → applicationBuildRunner-dj0xkrep.js} +26 -33
- package/buildBatch.proc.js +24 -15
- package/{cloud.command-kk2rwkvw.js → cloud.command-9cp1t0f5.js} +63 -65
- package/cloud.script-dgstqt1m.js +24 -0
- package/{code.command-30sca09d.js → code.command-0ad6gw2a.js} +6 -6
- package/{context.command-k7eqacq6.js → context.command-0qx447mb.js} +17 -13
- package/context.runner-1vsjztjg.js +25 -0
- package/{dependencyScanner-06z2dmrr.js → dependencyScanner-3afj2vt1.js} +1 -1
- package/{guideline.command-dtczrqpf.js → guideline.command-cv5bmrx2.js} +2 -2
- package/guidelines/agentRule/agentRule.instruction.md +3 -3
- package/guidelines/conventions/conventions.instruction.md +457 -1069
- package/guidelines/cssRule/cssRule.instruction.md +28 -7
- package/guidelines/fieldRule/fieldRule.instruction.md +62 -0
- package/guidelines/framework/framework.instruction.md +4 -7
- package/guidelines/mcpRule/mcpRule.instruction.md +48 -20
- package/guidelines/modelConstant/modelConstant.instruction.md +2 -1
- package/guidelines/modelStore/modelStore.instruction.md +9 -0
- package/guidelines/pageRule/pageRule.instruction.md +80 -0
- package/guidelines/queryRule/queryRule.instruction.md +44 -11
- package/guidelines/runtimeRule/runtimeRule.instruction.md +113 -17
- package/guidelines/ssrRule/ssrRule.instruction.md +13 -0
- package/guidelines/transportRule/transportRule.instruction.md +135 -13
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +4 -4
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +68 -7
- package/incrementalBuilder.proc.js +10 -7
- package/index-192ha4xh.js +1173 -0
- package/{index-c76k723e.js → index-1p98exm2.js} +2 -2
- package/{index-rwq3ekqc.js → index-1xj2t7cb.js} +7 -7
- package/{index-nz15k0b6.js → index-20bxxyvn.js} +80 -80
- package/{index-qt22xstj.js → index-35hc95h5.js} +5 -3
- package/{index-jyv387qw.js → index-4mhy6qme.js} +4 -4
- package/{index-ww9bhp67.js → index-4pf8g7ff.js} +1 -1
- package/{index-2mx692qq.js → index-5edzv8fn.js} +5 -5
- package/{index-k9rrten6.js → index-5nd1wtkv.js} +92 -94
- package/index-62md4dwy.js +260 -0
- package/index-665zp9rs.js +271 -0
- package/{index-2etcv0sc.js → index-86jt2a0e.js} +1 -1
- package/{index-2t4tqd1c.js → index-8fp3b47h.js} +419 -418
- package/{index-c2yrsxdh.js → index-9z426msb.js} +109 -328
- package/{index-ctanya41.js → index-awbdfcf2.js} +4 -4
- package/{index-3hx1z96c.js → index-bqbf654f.js} +1 -1
- package/{index-tn4rpqp1.js → index-bspm21y1.js} +73 -49
- package/index-cpb3nvma.js +667 -0
- package/{index-p9ystjrp.js → index-d47ypebv.js} +97 -55
- package/{index-0bm04n5g.js → index-d72hfmdm.js} +15 -13
- package/{index-e0cvze1s.js → index-fmzcs8v6.js} +4 -4
- package/{index-y8q31011.js → index-fyq1z6kt.js} +68 -177
- package/{index-kjvrq3nc.js → index-g1mbppa9.js} +331 -250
- package/{index-gnyf1fqy.js → index-j237xtew.js} +6 -6
- package/{index-fm46xrf1.js → index-j4mkhz93.js} +3 -3
- package/{index-6jjtnhkw.js → index-jw1zm273.js} +74 -34
- package/{index-q958tqqg.js → index-m9jj544v.js} +1 -1
- package/index-mdjc93e1.js +33 -0
- package/{index-c1x0be5j.js → index-pydbnfez.js} +2 -2
- package/{index-7bymavqk.js → index-qwa5k1j8.js} +1 -1
- package/{index-v29746vb.js → index-tamvyvj3.js} +3 -3
- package/{index-05g3jayg.js → index-trpasv9q.js} +260 -259
- package/{index-2ahj896a.js → index-tvehn2sp.js} +1 -1
- package/{index-55mynnqs.js → index-va06nr10.js} +1434 -1173
- package/index-vk0pa0mn.js +240 -0
- package/{index-aprt7pkz.js → index-w44cy4vs.js} +2271 -3369
- package/index.js +22 -22
- package/{library.command-gaga5bmq.js → library.command-erp2f56n.js} +3 -3
- package/{localRegistry.command-9e84e32q.js → localRegistry.command-y176p558.js} +15 -11
- package/{module.command-7vpf6db4.js → module.command-gs2r615e.js} +5 -5
- package/{package.command-twmvvf69.js → package.command-j10r76ax.js} +4 -4
- package/package.json +21 -21
- package/{page.command-5asq7fdf.js → page.command-4h4a8pnh.js} +3 -3
- package/{pipeline.command-d0v38572.js → pipeline.command-a2nrk26m.js} +221 -220
- package/{primitive.command-dyrs2fm8.js → primitive.command-w6t0asqz.js} +6 -6
- package/{projectTypecheck-cvtkrzp2.js → projectTypecheck-f534enqj.js} +1 -1
- package/{quality.command-6emsqpbg.js → quality.command-77x7vbbx.js} +4 -4
- package/{repair.command-8t6aegv5.js → repair.command-y6sstev7.js} +5 -5
- package/{scalar.command-bnggy1x7.js → scalar.command-20b8cy0k.js} +4 -4
- package/{subspace.command-f02j5tge.js → subspace.command-5c7yasd7.js} +424 -364
- package/templates/app/env/env.client.type.ts +2 -3
- package/templates/app/page/styles.css.template +8 -0
- package/templates/appSample/lib/task/Task.Util.tsx +7 -7
- package/templates/appSample/lib/task/Task.Zone.tsx +1 -1
- package/templates/appSample/ui/QuantityControl.tsx +2 -2
- package/templates/crudSinglePage/page.tsx +1 -1
- package/templates/lib/cnst.ts +0 -1
- package/templates/workspaceRoot/.gitignore.template +23 -52
- package/templates/workspaceRoot/biome.json.template +1 -1
- package/{tunnel.command-9x8nfn75.js → tunnel.command-2kw8vt9g.js} +8 -4
- package/{workflow.command-jx0wag5r.js → workflow.command-2djwmpqf.js} +10 -10
- package/{workspace.command-cmj6mqyk.js → workspace.command-6qfgq4b4.js} +38 -34
- package/TunnelShare-21mm6dr5.js +0 -9
- package/cloud.script-r7eyfey4.js +0 -20
- package/context.runner-hqd1ppqe.js +0 -21
- package/index-6w0rbr9d.js +0 -68
- package/index-n68jx79w.js +0 -120
- package/index-r2tpeb3n.js +0 -392
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Runtime Rule — Serving, Processes, Logging, Image, Native Apps, Desktop Server, Assets, Build Pipeline
|
|
1
|
+
# Runtime Rule — Serving, Processes, Dev Server, Logging, Image, Native Apps, Desktop Server, Assets, Build Pipeline
|
|
2
2
|
|
|
3
3
|
How an Akan app is built, what it serves, how many processes it runs, where its logs go, and what ends up in
|
|
4
4
|
its image. Everything here is declared in `akan.config.ts` or narrowed by an env at boot; none of it is reached
|
|
@@ -114,6 +114,58 @@ worker per web-serving replica.
|
|
|
114
114
|
`AkanServer`, whose graph the gateway never runs; through it the process evaluated 35MB of SSR renderer and
|
|
115
115
|
SQLite driver to spawn children and relay bytes. Keep entrypoint imports at the leaf.
|
|
116
116
|
|
|
117
|
+
## The Dev Server And Local Runs — `akan start`
|
|
118
|
+
|
|
119
|
+
- After changing application source code, test the app with `bun run akan start <appName>`; test production build
|
|
120
|
+
generation with `bun run akan build <appName>`.
|
|
121
|
+
- **`akan start` takes several apps** — `akan start a,b`, `akan start a b`, `akan start all`, or no argument at
|
|
122
|
+
all for a checkbox that remembers the last pick.
|
|
123
|
+
- **It opens a full-screen view by default, at any app count.** A rail of apps with the processes inside each
|
|
124
|
+
one — `host` for the dev host, gateway and RSC worker, plus a row per replica the gateway forwards — and the
|
|
125
|
+
selected one's log beside it. `Tab` walks every row and `1`-`9` jump to that app (the rail shows each app's
|
|
126
|
+
digit), `↑↓` scroll (shift for a page, `G` back to following), `/` greps, `e` shows stderr only, `c` clears,
|
|
127
|
+
`y` copies, `Y` copies the log path, `o` opens the browser, `r` restarts that app, `q` quits. Under `--share`,
|
|
128
|
+
`s` copies the public URL — the selected app's, or every app's from the merged row — and the header carries it
|
|
129
|
+
ahead of the local one, because the line `--share` printed before the view took the terminal is gone by its
|
|
130
|
+
first repaint. **`--plain` prints prefixed interleaved lines instead**, and a pipe, a redirect or an unsized
|
|
131
|
+
terminal downgrades to that on its own.
|
|
132
|
+
- **A supervised session writes `local/apps/<app>/runtime/dev.log`, and that path is how a log gets handed over.**
|
|
133
|
+
Ink repaints one frame in place, so the full-screen view leaves a bordered screenshot in the scrollback and takes
|
|
134
|
+
the rest of the session with it — a drag-selection is cleared by the next repaint, and what survives carries
|
|
135
|
+
borders and lines truncated to the pane. The file is the same output with no ANSI, nothing truncated, and every
|
|
136
|
+
process of the app in arrival order, including the dev host's own build output, which reaches no other file. The
|
|
137
|
+
previous session is kept beside it as `dev.prev.log`. Hand somebody — or an agent — the path rather than a paste:
|
|
138
|
+
it costs one line instead of a transcript, and it can be re-read after a fix. In the view, `y` copies the lines
|
|
139
|
+
the filters have already narrowed and `Y` copies the path.
|
|
140
|
+
- **How many apps boot at once is the machine's answer, not a constant.** A cold boot is an app's RSS peak (~1.8GB
|
|
141
|
+
per app, most of it the build worker — the base build, then the SSR registry's), so the default wave is what
|
|
142
|
+
memory and cores allow — half the memory budget divided by that peak, and one app per four cores — which boots a
|
|
143
|
+
laptop's apps together and still staggers them inside a small container. The session says which in one line.
|
|
144
|
+
The next wave starts once an app serves and its boot builds have settled, or 30 seconds after it serves, while
|
|
145
|
+
the app shows as ready — and `--open` opens it — as soon as it serves. `--concurrency <n>` overrides the wave
|
|
146
|
+
size, and `AKAN_MEMORY_LIMIT` lowers the budget it derives from.
|
|
147
|
+
- **`--kill` frees the dev ports first** — it resolves each port's listener, walks up to the top of that akan dev
|
|
148
|
+
tree and signals it, so another checkout's server or a stale orphan on the same port is reclaimed. A holder that
|
|
149
|
+
is not recognisably an akan process is reported and left alone.
|
|
150
|
+
- **A dev server watches its own app directory and the libs it actually depends on**, transitively, resolved once
|
|
151
|
+
at boot from the synced manifests — so a save under a lib the app never imports rebuilds and reloads nothing,
|
|
152
|
+
and neither does a save in a sibling app. An import that makes a new lib a dependency needs a fresh
|
|
153
|
+
`akan start`, because it needs a `sync` first anyway. Before the first `sync` the manifests do not exist yet and
|
|
154
|
+
the whole `libs/` container is watched, which is the safe direction.
|
|
155
|
+
- **A built artifact runs from the generated app directory** with the Akan runtime environment variables:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
cd dist/apps/<appName> && USE_AKANJS_PKGS=true AKAN_PUBLIC_REPO_NAME=<repo> AKAN_PUBLIC_SERVE_DOMAIN="<domain>" \
|
|
159
|
+
AKAN_PUBLIC_APP_NAME=<appName> AKAN_PUBLIC_ENV=local AKAN_PUBLIC_OPERATION_MODE=local SERVER_MODE=federation \
|
|
160
|
+
AKAN_PUBLIC_BASE_PATHS=<basePaths> bun main.js
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- **A server a test boots in-process takes `start({ listen: false, schedules: false })`** to run no internal
|
|
164
|
+
(init, interval, cron, queue worker); `listen: false` alone still runs them, as a batch replica does. A server
|
|
165
|
+
process a test spawns from source resolves the workspace from its cwd and gets the root `.env` from Bun, so
|
|
166
|
+
spawn it inside the repo with `bun --no-env-file`, or spawn the built `dist/apps/<app>` output, which needs
|
|
167
|
+
neither.
|
|
168
|
+
|
|
117
169
|
## Logging — Records, Request Context And Live Tail
|
|
118
170
|
|
|
119
171
|
Every `Logger` call builds a `LogRecord` before any text exists: `at`, `level`, OTel `sev`, `name`, `message`,
|
|
@@ -152,6 +204,18 @@ normalized to `info` with one warning.
|
|
|
152
204
|
- **The generated Dockerfile sets `AKAN_LOG_TO_FILE=0`.** A container's writable layer is ephemeral and nothing
|
|
153
205
|
collects a file from it; stdout is the collection path. A deployment that wants the files back sets
|
|
154
206
|
`AKAN_LOG_TO_FILE=1`.
|
|
207
|
+
- **`AKAN_LOG_FORMAT=ndjson` makes the container's stdout one JSON record per line, and the hub owner (gateway or
|
|
208
|
+
solo replica) its only writer** — every other server process turns its console off and forwards from
|
|
209
|
+
`AKAN_LOG_STDOUT_LEVEL` (default: the console level); a child's or the RSC worker's crash stack is wrapped as a
|
|
210
|
+
`raw=true` record instead of breaking the stream. Text stays the default; turn it on in the deployment.
|
|
211
|
+
- **`AKAN_LOG_STREAM_TOKEN` mounts `/_akan/app/logs`** (SSE, `Last-Event-ID` resume, explicit `gap` events);
|
|
212
|
+
unset, the route does not exist.
|
|
213
|
+
- **`AKAN_LOG_CANONICAL=1|slow`** writes one `ok|error <endpoint> ms= status=` line per call; **`AKAN_LOG_FLIGHT=1`**
|
|
214
|
+
keeps each call's last 64 sub-level records and promotes them (`flight=true`) only when it failed or ran past
|
|
215
|
+
`AKAN_LOG_FLIGHT_MS`; an `x-akan-debug` header lowers one request to `trace` — in `local`, or with the
|
|
216
|
+
`AKAN_LOG_DEBUG_HEADER` secret.
|
|
217
|
+
- **Structured values go in `LogRecord.attrs` via `Logger.emit(...)`**; a key naming a secret is `[redacted]`
|
|
218
|
+
before the record exists.
|
|
155
219
|
- **Never log per delivered record in anything that delivers records.** The control socket and the hub do not,
|
|
156
220
|
and a subscriber asking for everything is the test.
|
|
157
221
|
|
|
@@ -257,9 +321,10 @@ const config: AppConfig = {
|
|
|
257
321
|
Intel Macs are not a target, so `--arch x64` on macOS is refused.
|
|
258
322
|
- **A release is signed only with `--sign` and notarized only with `--notarize`** (`build-desktop`,
|
|
259
323
|
`publish-update`); the environment names the identity and never decides to use it, so a shell exporting a Developer
|
|
260
|
-
ID signs and uploads nothing unasked.
|
|
261
|
-
|
|
262
|
-
the
|
|
324
|
+
ID signs and uploads nothing unasked. Every release build and every `publish-update` of a target that carries a
|
|
325
|
+
server, signed or not, first searches that server for every secret value of the workspace's server env files and
|
|
326
|
+
refuses with the paths; an env file it cannot evaluate stops the release too, and a debug build is not searched.
|
|
327
|
+
One line then names the identity and the service the app goes to.
|
|
263
328
|
- **A macOS release for download is signed with a Developer ID and notarized.**
|
|
264
329
|
`AKAN_NATIVE_MACOS_IDENTITY` names a keychain identity, or `AKAN_NATIVE_MACOS_CERTIFICATE` +
|
|
265
330
|
`_CERTIFICATE_PASSWORD` a `.p12` the build imports into a keychain of its own (a CI runner);
|
|
@@ -280,12 +345,15 @@ const config: AppConfig = {
|
|
|
280
345
|
key. List the online signing key first and offline spares after it (up to three): a phone binary trusts all of
|
|
281
346
|
them, and a key may revoke only the keys listed before it, so a leaked key cannot lock out its spares. A desktop
|
|
282
347
|
app trusts the first; `publicKey` is the one-key form. `akan publish-update <app>` builds and signs a release
|
|
283
|
-
into `updates/`: upload that
|
|
284
|
-
(credentials and endpoint from `S3_*` or `AWS_*`; a release file the bucket
|
|
285
|
-
|
|
348
|
+
into `updates/`: upload that release's files to `url` and its manifest last, or let
|
|
349
|
+
`--upload s3://<bucket>/<prefix>` do it (credentials and endpoint from `S3_*` or `AWS_*`; a release file the bucket
|
|
350
|
+
holds is skipped), which sends that release alone, and nothing when it is missing or fails a check. Never upload the
|
|
351
|
+
folder whole: a phone's is also where `pack-update` writes by default.
|
|
352
|
+
A phone reads only `<url>/<platform>/<channel>/<nativeApi>.json`, so each store generation on a channel takes its
|
|
353
|
+
own fixes.
|
|
286
354
|
`akan pack-update` writes an unsigned phone bundle for whoever holds the key to sign and records it in
|
|
287
355
|
`<out>/pack.json`, exiting 2 when `--against` says it needs a new store build; `akan push-update` packs and uploads to
|
|
288
|
-
|
|
356
|
+
the Akan Cloud project that holds the key, and `akan register-binary` registers a store build after `release-*`. A
|
|
289
357
|
release that hangs is rolled back while the app runs, and `report: { url }` has a phone post what became of each
|
|
290
358
|
release (applied, confirmed, rolled back and why) at its next launch, so rollback rates are visible. `devtools: true`
|
|
291
359
|
opens the web inspector of a release build for an internal test target. The channel is the backend env the binary was built for unless `channel` names one;
|
|
@@ -307,10 +375,11 @@ const config: AppConfig = {
|
|
|
307
375
|
their page paints, so a dark board never flashes white, and `desktop.publisher` names who publishes a Windows app in
|
|
308
376
|
Apps & features and its executable's company (the app name when unset, never Bun's own).
|
|
309
377
|
- **A third-party page shown first-party** — a scoreboard, a menu board — is the `external-view` plugin, listed in
|
|
310
|
-
`native.plugins`: a webview over the window with a browser profile of its own, held to the origins it
|
|
311
|
-
for
|
|
312
|
-
|
|
313
|
-
`
|
|
378
|
+
`native.plugins`: a webview over the window with a browser profile of its own, its top level held to the origins it
|
|
379
|
+
was opened for while its frames load web content from anywhere, with an init script that runs in the top document
|
|
380
|
+
only, `evaluate`, a trusted `sendKey`, `capture`, and `loadFailed` / `gone` (it reloads, or is rebuilt, by itself;
|
|
381
|
+
the reason is `terminated` on macOS, and `rebuildFailed` when a rebuild failed and the view is closed, to be opened
|
|
382
|
+
again) / `consoleMessage` events. Every method on Windows; macOS 14+ lacks `sendKey`, `setMuted` and `capture`.
|
|
314
383
|
|
|
315
384
|
## A Desktop App's Server — `bin` And `trustedDependencies`
|
|
316
385
|
|
|
@@ -326,7 +395,7 @@ has none, or none when it has one, is refused, and switching means a reinstall.
|
|
|
326
395
|
tied to the image's system, code only a process the app never starts loads — and whatever only they pull in; the
|
|
327
396
|
image keeps them, and the server's own bundle must not import them. A package another dependency still installs
|
|
328
397
|
stops the build, naming who needs it. So does a native addon that would not load on a user's computer: no binary
|
|
329
|
-
for the app's OS and CPU, a link outside the system, or a `binding.gyp` its install never compiled. It runs as an API-only edge server
|
|
398
|
+
for the app's OS and CPU, a link outside the system, or a `binding.gyp` its install never compiled. It runs as an API-only edge server in database mode `single` (SQLite), on a loopback port the launcher picks — the last session's when
|
|
330
399
|
it is free, which is not a guarantee, so a provider that needs an exact redirect URI signs in through a cloud
|
|
331
400
|
server's adapter. It refuses a `Host` other than its own, but any program on the computer can still call that port:
|
|
332
401
|
guard its endpoints as a network server's. It runs in one process, so a `main.ts` or an env asking for replicas (`replica`,
|
|
@@ -416,10 +485,14 @@ in those alone, and `modules: ["@edge"]` (or `AKAN_MODULES=@edge`) names every m
|
|
|
416
485
|
inject, cascade to and hold a relation to. `desktop: { server: { profile: "edge", modules: ["@edge"] } }` builds the
|
|
417
486
|
carried server that way: `env/env.server.<env>.edge.ts` is bundled in place of `env/env.server.<env>.ts`, which never
|
|
418
487
|
enters it, and the build evaluates the profile file with no app env and refuses one whose secret-named keys hold a
|
|
419
|
-
value — a `process.env.X ?? "literal"` counts as the literal.
|
|
488
|
+
value — a `process.env.X ?? "literal"` counts as the literal. Signing, encryption, master and session keys, `pwd`, a
|
|
489
|
+
`…dsn` and a connection string are secret-named, and a URL holding a password or a secret-named query parameter is a
|
|
490
|
+
secret under any key. The profile may spread `libEnv`, and the lib defaults it reaches are checked as the bundle
|
|
491
|
+
carries them; a profile that imports the file it replaces is refused. Each lib's env defaults (`env.server.testing.ts`) are
|
|
420
492
|
swapped the same way: for the lib's `env/env.server.testing.edge.ts`, held to the same check, or for the defaults with
|
|
421
|
-
every secret-named value left out, which the build names in a warning
|
|
422
|
-
|
|
493
|
+
every secret-named value left out, which the build names in a warning — and a default a JSON snapshot cannot carry (a
|
|
494
|
+
function, Map, Set, Date, class instance, bigint or non-finite number) is refused unless the lib has its profile file.
|
|
495
|
+
`akan build <app> --profile edge` does the same for an image. A module listing only edge does not mount under `akan start` (local): list `local` too to develop
|
|
423
496
|
it there.
|
|
424
497
|
|
|
425
498
|
**A file the user picks reaches the server as a grant, never as a copy or a path.** With `"file-picker"` in
|
|
@@ -444,6 +517,29 @@ page API is
|
|
|
444
517
|
hook; `volume` and `filePicker` also come from `akanjs/client/native` itself. A capability the shell
|
|
445
518
|
lacks is added there: an app's own plugin runs as Bun code in the plugin host and cannot add a native shell op.
|
|
446
519
|
|
|
520
|
+
**A plugin the app or lib owns lives in `native/<id>/`**, the folder named after the plugin id: `native-plugin.json`
|
|
521
|
+
beside its page API (`src/index.ts`, `definePlugin` from `akanjs/client/native`), its desktop part (`src/desktop.ts`,
|
|
522
|
+
`defineDesktopPlugin` from `akanjs/native/desktop`), Kotlin and Swift. Every mobile and desktop target ships it; a
|
|
523
|
+
lib's reach only the apps that depend on it, and the app's own wins an id. Its page API is imported by a `webkit/`
|
|
524
|
+
hook (`../native/<id>/src`) and nothing else; pages and components call the hook, the same way they reach
|
|
525
|
+
`akanjs/client/native`. A plugin throws `AkanNativeError`, the error its bridge carries back to the page, which is
|
|
526
|
+
why `no-throw-raw-error` exempts a root `native/` folder.
|
|
527
|
+
|
|
528
|
+
**A page's storage and browser APIs follow the shell, not the browser.** A native shell serves the page from
|
|
529
|
+
`app://localhost` on iOS, macOS and Linux, which keeps no cookies: `document.cookie` reads `""` and a write is
|
|
530
|
+
dropped, so the value exists in the browser and vanishes in the app. Web Storage does work in the shells, but it
|
|
531
|
+
bypasses the store akanjs picks per platform (the shell's Preferences in an app) and throws during SSR. So app and lib
|
|
532
|
+
code never touches `document.cookie`, `localStorage` or `sessionStorage` (`no-document-cookie`, `no-web-storage`): it
|
|
533
|
+
uses `getCookie` / `setCookie` / `removeCookie`, `storage`, and `secretStorage` for tokens and credentials, all from
|
|
534
|
+
`akanjs/client`. `setCookie` writes `path=/; SameSite=Lax; Secure`, and an options object overrides only the keys it
|
|
535
|
+
names — pass `sameSite: "none"` only for a cookie a cross-site POST must carry back. Browser APIs an app WebView lacks
|
|
536
|
+
stay in a `webkit/` hook that branches on `isNativeApp()` from `akanjs/client/native`, which is where the app side goes
|
|
537
|
+
too (`no-web-only-api-outside-webkit`, a warning): `navigator.share` / `canShare` (Android's WebView has none),
|
|
538
|
+
`navigator.serviceWorker` and `Notification.requestPermission` / `.permission` (no app page has them),
|
|
539
|
+
`navigator.geolocation` (use `useGeoLocation` from `akanjs/webkit`) and `navigator.vibrate` (iOS has none; use
|
|
540
|
+
`haptics`). They are legal on the web, so the rule does not ban them; a root `native/` folder is out of its scope,
|
|
541
|
+
since a plugin's web part is where those APIs belong.
|
|
542
|
+
|
|
447
543
|
## Database Modes — `database` In `akan.config.ts`
|
|
448
544
|
|
|
449
545
|
| mode | database | cache · queue · pubsub | runs as |
|
|
@@ -556,7 +652,7 @@ const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve,
|
|
|
556
652
|
|
|
557
653
|
Akan Cloud builds and deploys each app as `build` → `docker` → `deploy`, and `ota` after them when asked. **An app's own stages go in
|
|
558
654
|
`apps/<app>/akan.config.ts` under `pipeline`; stages around every app go in the workspace root's
|
|
559
|
-
`akan.config.ts`**, typed `WorkspaceConfig` (`before`, `after`, `post`, `env`, `timeout`). Both are `import type`
|
|
655
|
+
`akan.config.ts`**, typed `WorkspaceConfig`, under its own `pipeline` (`before`, `after`, `post`, `env`, `timeout`). Both are `import type`
|
|
560
656
|
objects (or `(ctx) => …`), and `akan pipeline dry-run | validate | plan` turns them into the plan Akan Cloud runs —
|
|
561
657
|
locally, running nothing.
|
|
562
658
|
|
|
@@ -38,6 +38,19 @@ A third-party client package and the `ui/<Folder>/index_.tsx` + `lazy()` boundar
|
|
|
38
38
|
not flagged. Interaction-driven `fetch.*` inside an `onClick` is not flagged either — only mount-time loads are,
|
|
39
39
|
because those are the ones the server could have performed.
|
|
40
40
|
|
|
41
|
+
## Lint Rules Behind The Boundary
|
|
42
|
+
- `no-async-component-in-ui` (`{apps,libs}/**/ui/**/*.tsx`) — React has no async client component, so a `ui/`
|
|
43
|
+
component that awaits breaks as soon as a client parent renders it, and the load drops below the route, which
|
|
44
|
+
could have started it before the first byte. Only a PascalCase binding whose own initializer is `async` is matched,
|
|
45
|
+
so an async handler inside a synchronous component and `lazy(async () => import(…))` are untouched.
|
|
46
|
+
- `no-init-fetch-in-client` — `fetch.init<Model><Suffix>` and `fetch.get<Model>Init<Suffix>` compose the slice's list
|
|
47
|
+
and insight queries into the hydration snapshot `Load.Units` / `Load.View` seed the store from. From a route it
|
|
48
|
+
resolves before the first byte; after hydration it is two extra round-trips for a shell the browser already
|
|
49
|
+
painted. Load it in the page and pass the result (or the unawaited `x<Model>Init<Suffix>` promise) down as `init`,
|
|
50
|
+
and reload from the client through the generated `st.do.init<Model><Suffix>()`. The rule is gated on the real
|
|
51
|
+
`"use client"` directive plus `*.store.ts` and matches by shape (`init` + `Capitalize<refName>` +
|
|
52
|
+
`Capitalize<suffix>`), so a hand-written `initPayment` is out.
|
|
53
|
+
|
|
41
54
|
## Implementation Playbook
|
|
42
55
|
|
|
43
56
|
**① Wrap the interaction, not the UI.** The smallest useful client component adds one behaviour and renders
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
# Transport Rule — Guards, Sockets,
|
|
1
|
+
# Transport Rule — Guards, Sockets, Deadlines, Rate Limits, Server-To-Server Calls, Bytes, HTTP Verbs
|
|
2
2
|
|
|
3
3
|
How a call reaches a signal: what a guard may read on either transport, how a websocket carries its credential
|
|
4
|
-
and its per-connection state,
|
|
5
|
-
|
|
4
|
+
and its per-connection state, how long a call may take and what it may cache or count, how one server calls
|
|
5
|
+
another, which body types and bytes cross the wire, when a pubsub payload leaves JSON for a binary frame, and which
|
|
6
|
+
HTTP verb a mutation is mounted under. `conventions` carries the invariants — this is the full contract behind them.
|
|
6
7
|
|
|
7
8
|
## Guards And Transports
|
|
8
9
|
|
|
9
10
|
- Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` (`pkgs/akanjs/signal/signalContext.ts`) instead of branching on `getHttpContext()` / `getWebSocketContext()`.
|
|
10
11
|
- Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is unguarded unless it declares its own `guards` in its signal option.
|
|
12
|
+
- **Every endpoint that declares no guards is named in a boot `warn`**, since it answers anyone over HTTP and
|
|
13
|
+
WebSocket — a named slice's `init()`, a root slice, a `pubsub`/`message`/`query`/`mutation`. Write
|
|
14
|
+
`guards: [Public]` to keep one open on purpose.
|
|
15
|
+
- **A generated verb is taken off HTTP with `guards: { cru: false }`** (or `get`/`create`/`update`/`remove:
|
|
16
|
+
false`), and a verb whose guards resolve to none is not mounted at all. `mcp: { cru: false }` does **not** do
|
|
17
|
+
this: it keeps the verbs off the agent shelf while HTTP serves them to whoever passes their guards.
|
|
11
18
|
- A pubsub room is authorized once, at subscribe. When a socket's credential changes the framework re-runs each room's guards and unsubscribes the ones that now fail (`SignalResolver.revalidateWsRooms`), so guards must stay side-effect free and safe to re-run. Each re-check runs on a fresh `SignalContext`, so a guard that memoizes per context answers anew.
|
|
12
19
|
- **Arguments are parsed after the `"account"` guards** (`SignalContext.init` reads the body, `exec` parses): the order is middlewares → `account` guards → parse → `resource` guards → internal arguments → handler, whatever order `guards: [...]` lists them in. A caller an account guard refuses never reaches a scalar parser, which is what keeps a slow parser from being a pre-auth denial of service. A guard or middleware that reads `context.args` / `getArg()` earlier still gets the value, parsed on first read — a mismarked account guard keeps working. One visible consequence: an unauthenticated call with a malformed argument answers 401/403 instead of 400.
|
|
13
20
|
- A websocket carries its credential in the handshake snapshot on `ws.data` (`AppWsData`); clients that hold the token in memory send it with `fetch.setJwt(...)`, which forwards an auth frame over the socket.
|
|
@@ -24,7 +31,8 @@ mutation is mounted under. `conventions` carries the invariants — this is the
|
|
|
24
31
|
- **A device or another server proves itself with a machine token.** `MachineToken.issue({ kind, id, version },
|
|
25
32
|
{ expiresAt? })` in `akanjs/server` signs an EdDSA JWT (`typ: akan-machine+jwt`, `aud: <app>/<environment>`) with
|
|
26
33
|
`AKAN_MACHINE_PRIVATE_KEY`; it never expires unless asked. `akan machine-keygen <file>` writes that key to a file
|
|
27
|
-
and prints only the public one. A server with either key
|
|
34
|
+
and prints only the public one. A server with either key (`AKAN_MACHINE_PRIVATE_KEY` or
|
|
35
|
+
`AKAN_MACHINE_PUBLIC_KEY`) verifies the bearer of every call — HTTP, and the
|
|
28
36
|
websocket's auth frame — after every lib middleware, and sets `account.machine = { kind, id, version }` from a
|
|
29
37
|
verified token; it takes off a `machine` any other middleware or a session's claims put there, so a staff token
|
|
30
38
|
can never pass for one, and a session middleware already reads the EdDSA token as anonymous. `.with(Machine)` hands
|
|
@@ -47,14 +55,37 @@ mutation is mounted under. `conventions` carries the invariants — this is the
|
|
|
47
55
|
change revokes it, `disconnect` when the socket closes while still subscribed — so a room already left runs
|
|
48
56
|
neither again, and cleanup that must happen either way registers for both. From a `message` handler it belongs to
|
|
49
57
|
the socket and runs at close. A handler that throws is logged and never blocks the rest of the teardown.
|
|
58
|
+
- **A subscribe the client leaves before it finishes joins nothing.** When an `unsubscribe` for the room, or the
|
|
59
|
+
socket's close, arrives while the subscribe is still running, the subscribe answers `subscribe: false` and runs its
|
|
60
|
+
`unsubscribe` cleanup (and its `disconnect` one when the socket closed) instead of joining the room.
|
|
61
|
+
|
|
62
|
+
## Writing A Guard
|
|
63
|
+
|
|
64
|
+
- **Resource guards are `Can<Verb><Model>` classes in `srvkit/guards.ts`** that `implements Guard` with an
|
|
65
|
+
`async canPass(context)`. They **fail closed**: no resource named ⇒ `false`; a load that throws ⇒ `logger.warn`
|
|
66
|
+
then `false`. Admin bypass goes first.
|
|
67
|
+
- **Keep `static name = "User";` on guard classes.** `fetch` serializes guard names and the API explorer filters
|
|
68
|
+
on them; it looks like dead code, and deleting it breaks the UI. Comment it so the next reader knows.
|
|
69
|
+
- **Every guard class declares `static scope: GuardScope`, required with no default.** `"account"` means the
|
|
70
|
+
verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what
|
|
71
|
+
lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments
|
|
72
|
+
(`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays
|
|
73
|
+
visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than
|
|
74
|
+
defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`.
|
|
75
|
+
What a mismarked guard does to a listing, and `static agents = false` on `Person`: `mcpRule`.
|
|
76
|
+
- **Guards ship with the library that owns the model** and are imported by its own signals through the package
|
|
77
|
+
path, so a mounting app inherits authorization and cannot forget it.
|
|
78
|
+
- The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`; never trust a client-supplied
|
|
79
|
+
id. Services re-check ownership even when a guard already gated the call — two independent gates.
|
|
80
|
+
- `srvkit/guards.ts` earns real comments: explain what would leak without each guard.
|
|
50
81
|
|
|
51
82
|
## Request Deadlines
|
|
52
83
|
|
|
53
84
|
- **`{ timeout: <ms> }` in an endpoint's signal option is the whole configuration surface**, and it reaches both
|
|
54
85
|
ends of the call. Server side, the `Timeout` middleware races the handler against it and answers
|
|
55
86
|
`base.error.gatewayTimeout` (504) once it is spent. Client side, `FetchSerializer` ships the value on the
|
|
56
|
-
serialized endpoint and `fetch.<endpoint>()` uses it as that
|
|
57
|
-
browser does not abandon a call the server is still allowed to be working on.
|
|
87
|
+
serialized endpoint and `fetch.<endpoint>()` uses it as that call's deadline — every retry of the call and every
|
|
88
|
+
wait between them included — so the browser does not abandon a call the server is still allowed to be working on.
|
|
58
89
|
- **The middleware is registered by default and has no default of its own.** An endpoint that declares no
|
|
59
90
|
`timeout` is passed straight through — a default here would put a deadline on every endpoint in the app that
|
|
60
91
|
nobody asked for, which is why the middleware used to be opt-in and, being opt-in, made the declared value
|
|
@@ -62,7 +93,9 @@ mutation is mounted under. `conventions` carries the invariants — this is the
|
|
|
62
93
|
five-minute budget that is a five-minute hold on the event loop for a call that answered in a millisecond.
|
|
63
94
|
- **Without a declaration the client's own default applies — 30 seconds** (`DEFAULT_TIMEOUT_MS`,
|
|
64
95
|
`fetch/client/httpClient.ts`), and the server imposes nothing. Nothing else bounds a request: `Bun.serve` runs
|
|
65
|
-
with `idleTimeout: 0`, so a long call is only ever ended by one of these two.
|
|
96
|
+
with `idleTimeout: 0`, so a long call is only ever ended by one of these two. An endpoint whose real work runs
|
|
97
|
+
longer — provisioning, a firmware flow, an external orchestration — therefore has to declare one, or it dies
|
|
98
|
+
mid-flight with a transport error that does not say whether the server ever received it.
|
|
66
99
|
- **Precedence is caller, then endpoint, then client default.** `fetch.x(…, { timeout: 5_000 })` overrides what
|
|
67
100
|
the endpoint declared; `{ timeout: false }` waits as long as the runtime will. An app moves its own default
|
|
68
101
|
with `fetch.instance.setTimeout(ms)` — `lib/useClient.ts` is generated, so the `timeout` option on
|
|
@@ -92,13 +125,65 @@ mutation is mounted under. `conventions` carries the invariants — this is the
|
|
|
92
125
|
- **The entry is the handler's result, not the response.** The lookup wraps the handler only, so `resolveReturn`
|
|
93
126
|
still runs per call on the cached value — field masking, `hidden`/`secret` stripping and relation resolution are
|
|
94
127
|
never cached, and a relation still costs its own load. The cache saves the handler, not the serialization.
|
|
128
|
+
- **A hit hands `resolveReturn` an instance of the declared return model**, rebuilt from an entry that keeps its
|
|
129
|
+
`Map`s, bytes and dates, so a `resolveField` reads the same methods and `Dayjs` values on a hit as on a miss. It is
|
|
130
|
+
the declared model, not the service's document: a method only the document has is not there on a hit, and a return
|
|
131
|
+
declared as a `Light` model shows `resolveField` its light fields only. The entry still holds `hidden` and `secret`
|
|
132
|
+
values for its TTL, as the handler returned them.
|
|
133
|
+
- **A write drops the answers it made stale.** Plug the adaptor into the service and call it after the write:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
export class BannerService extends serve(db.banner, ({ plug }) => ({
|
|
137
|
+
endpointCache: plug(EndpointCacheControl),
|
|
138
|
+
})) {
|
|
139
|
+
override async _postUpdate(banner: db.Banner) {
|
|
140
|
+
await this.endpointCache.invalidate("bannerListInPublic");
|
|
141
|
+
await this.endpointCache.invalidate("bannerInsightInPublic");
|
|
142
|
+
return banner;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`invalidate(endpoint)` drops every cached answer of that endpoint, across instances. A slice's `init({ cache })`
|
|
148
|
+
caches its `<model>Insight<Slice>` as well as its list, so drop both or the count stays stale.
|
|
149
|
+
`invalidate(endpoint, args)` drops the one call with those arguments: pass every declared argument in order as the
|
|
150
|
+
endpoint receives it, `null` for an omitted search argument (search arguments are nullable, so an omitted one is
|
|
151
|
+
stored as `null`). A slice list also carries `skip` / `limit` / `sort`, so drop it with the endpoint-wide form.
|
|
152
|
+
A mismatched list misses silently and still answers `true`.
|
|
153
|
+
- **How it is keyed:** every entry key carries its endpoint's generation, a random token the endpoint-wide form
|
|
154
|
+
replaces in one write. Nothing is scanned, and a miss already running when you invalidate writes under the old
|
|
155
|
+
token, where nobody reads it. A token is never reused, so a generation that expires (after a day) or is evicted
|
|
156
|
+
starts a fresh one instead of reviving dropped answers, and an endpoint name nothing serves leaves no key behind for
|
|
157
|
+
good. **A `{ cache }` TTL is capped at one day**, the generation's life: an entry outliving its token could never be
|
|
158
|
+
read, so a longer TTL is written as a day. Entries under an old token stay in the cache until their own TTL, and a
|
|
159
|
+
Redis that BullMQ shares has to run `noeviction`, so nothing frees them early: give an endpoint you invalidate often
|
|
160
|
+
a short TTL. The one-call form
|
|
161
|
+
deletes that entry, so a miss for those arguments already running can write it back; reach for the endpoint-wide
|
|
162
|
+
form when that matters. It answers `false` (logged as an error) when the cache failed. A cached call reads the
|
|
163
|
+
generation before its entry.
|
|
95
164
|
- **A cache backend that is down does not take the endpoint down.** A failed read is warned and the call runs
|
|
96
165
|
uncached; a failed write is warned and dropped.
|
|
97
|
-
-
|
|
98
|
-
`
|
|
99
|
-
handler: a hung cache backend is bounded by the same
|
|
166
|
+
- A call runs the cross-site check and an `ip` rate limit, then the body read, then the default middleware chain
|
|
167
|
+
`Logging → Timeout → <lib middlewares>`, and inside it an `account` rate limit → account guards → argument
|
|
168
|
+
parse → resource guards → internal arguments → cache → handler: a hung cache backend is bounded by the same
|
|
169
|
+
deadline as the handler it stands in for. **`Retry` was removed** — an automatic re-run of an endpoint
|
|
100
170
|
whose failure it cannot classify replays whatever the first attempt already did.
|
|
101
171
|
|
|
172
|
+
## Repeated Mutations
|
|
173
|
+
|
|
174
|
+
- **A mutation sent again under the same `Idempotency-Key` header runs once.** The key is checked after the
|
|
175
|
+
resource guards and the internal arguments, and it is scoped to the endpoint, the caller and a digest of the
|
|
176
|
+
arguments, so one caller's key never hands another caller its answer. A repeat while the first run is still going
|
|
177
|
+
answers 409 `base.error.idempotencyInFlight`.
|
|
178
|
+
- **A repeat answers the first response as it was sent** — resolved, masked and serialized — for 24 hours. What
|
|
179
|
+
is kept is that wire form, never the handler's result, so no `hidden` or `secret` field reaches the cache.
|
|
180
|
+
- A handler that throws frees the key, so a repeat runs it again. A failure after the handler returned is kept and
|
|
181
|
+
replayed instead, for the same 24 hours and a transient one (a relation read during a database blip) included:
|
|
182
|
+
the handler's writes stand, so running it again would repeat them.
|
|
183
|
+
- **Only an HTTP call is deduplicated.** A signed-out call, one whose handler returns a `Response` (whose headers,
|
|
184
|
+
such as a session's `Set-Cookie`, cannot be replayed), a websocket call, and a call through MCP all run every
|
|
185
|
+
time.
|
|
186
|
+
|
|
102
187
|
## Rate Limits
|
|
103
188
|
|
|
104
189
|
- **`{ rateLimit: { calls, windowMs?, by? } }` in an endpoint's signal option** counts calls per caller per window
|
|
@@ -107,9 +192,9 @@ mutation is mounted under. `conventions` carries the invariants — this is the
|
|
|
107
192
|
checked after the middlewares and before the guards, keyed by `accountKey(account)`; a caller it names nothing
|
|
108
193
|
for — an anonymous one — is counted by address. Leaving a pubsub room is never counted.
|
|
109
194
|
- **The answer is 429 `base.error.tooManyRequests` with `data: { seconds }`** and a `Retry-After` header; a WS
|
|
110
|
-
call gets the same payload as its error frame, and the HTTP client restores a proxy's own
|
|
111
|
-
same key (seconds from its `Retry-After`, else 60). The first
|
|
112
|
-
later ones log at `debug`, so a flood does not become a log flood.
|
|
195
|
+
call gets the same payload as its error frame, and the HTTP client restores a proxy's own 429 — a page, or JSON
|
|
196
|
+
naming no error key of ours — under the same key (seconds from its `Retry-After`, `0` included, else 60). The first
|
|
197
|
+
refusal per endpoint per process is warned once; later ones log at `debug`, so a flood does not become a log flood.
|
|
113
198
|
- **Keys and addresses.** The address is `context.getClientIp()` — the trusted-proxy answer, never a raw header
|
|
114
199
|
from an untrusted peer. Callers whose address is unknown share one `unknown` budget (fail closed), and the first
|
|
115
200
|
such call is warned about, since it usually means the proxy in front records no `X-Real-IP`/`X-Forwarded-For`
|
|
@@ -134,6 +219,43 @@ mutation is mounted under. `conventions` carries the invariants — this is the
|
|
|
134
219
|
`{ ok: true } | { ok: false, retryAfterMs }` and is never switched off — throw the module's own `Err` with the
|
|
135
220
|
wait when it refuses.
|
|
136
221
|
|
|
222
|
+
## Calling Another Server
|
|
223
|
+
|
|
224
|
+
- **A server-side `fetch.clone({ origin })` opens no websocket unless `connect: true`**, and neither does a clone
|
|
225
|
+
of such a clone: it makes one-off calls to another server, and a socket it opened would reconnect forever with
|
|
226
|
+
nobody to close it. It never carries the visitor's session either: a call to another origin sends only the `jwt`
|
|
227
|
+
its caller gave the clone.
|
|
228
|
+
- **Retry a call with `retry`, never with a loop of your own.** `fetch.clone({ origin, retry: {} })` (or a call's
|
|
229
|
+
own `{ retry }`) tries again on an unreachable server, a 502/503/504 and a 429 after its Retry-After, backing off
|
|
230
|
+
with jitter until the call's one deadline runs out. A mutation retries under one `Idempotency-Key` it mints, or
|
|
231
|
+
the `idempotencyKey` you name, and the server runs it once per key, caller and arguments (Repeated Mutations
|
|
232
|
+
above); a signed-out call, one whose handler returns a `Response`, and a call through MCP run every time. A 4xx
|
|
233
|
+
is never retried.
|
|
234
|
+
- **A failure another process reported travels as itself.** A server-to-server `fetch.<endpoint>(..., { origin })`
|
|
235
|
+
restores the remote `Err` — the same class, key and `data` — so a hop that rethrows it answers its own caller
|
|
236
|
+
with that error and the dictionary translates it. Never wrap the catch in a `new Error`, and never re-key it as a
|
|
237
|
+
`new Err` of your own: both discard what the endpoint chose to say, and a bare `Error` is generalized to
|
|
238
|
+
`Internal Server Error` on the way out.
|
|
239
|
+
- `String(err)` and `${err}` read `Err: <key> {…data}`, the data as one line of JSON with secret-named keys masked,
|
|
240
|
+
so a ``logger.warn(`…: ${String(error)}`)`` keeps the exit code or status the `Err` carried.
|
|
241
|
+
|
|
242
|
+
## Body Types And Bytes
|
|
243
|
+
|
|
244
|
+
- `.body(...)` / `.param(...)` args accept `ConstantFieldTypeInput` only: scalars, model refs, or `enumOf(...)`.
|
|
245
|
+
Numbers are `Int` or `Float` — `Number` is rejected (`pkgs/akanjs/signal/endpointInfo.ts`).
|
|
246
|
+
- **`Upload` is valid only inside a mutation flagged for file upload**:
|
|
247
|
+
`mutation([cnst.File], { fileUpload: true }).body("files", [Upload])`, as the `file` module does. It is not a
|
|
248
|
+
model field type. The other body args of such a mutation arrive typed as in a JSON body. A file's `type` on the
|
|
249
|
+
server is what its name implies, not its bytes — check the bytes when the type matters.
|
|
250
|
+
- **`Binary` is raw bytes on the wire.** It is `Uint8Array` on both sides — a Node `Buffer` is one, so a server
|
|
251
|
+
handler may pass one straight in — and it accepts base64 in either direction, so the same declaration serves a
|
|
252
|
+
JSON body and a binary frame. A `Binary` return travels as base64 in an HTTP body. It is never a model field
|
|
253
|
+
(`fieldRule`).
|
|
254
|
+
- **Never send bytes as `Any`.** `Any` passes a `Buffer` through untouched and `JSON.stringify` then spells it
|
|
255
|
+
`{ type: "Buffer", data: number[] }`, which `JSON.parse` never restores; the result is 3.6x the wire and a shape
|
|
256
|
+
that only breaks at the first byte-offset read. A return declared `Any` that turns out to be bytes fails instead
|
|
257
|
+
of reaching the caller as `{}`, and MCP refuses a `Binary` return for the same reason it refuses `Any`.
|
|
258
|
+
|
|
137
259
|
## Binary Pubsub
|
|
138
260
|
|
|
139
261
|
- **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON `{ type: "pub" }`
|
|
@@ -230,10 +230,10 @@ Six worked recipes carry the full code for the most frequent Akan changes, toget
|
|
|
230
230
|
| Recipe | Covers |
|
|
231
231
|
|---|---|
|
|
232
232
|
| 1. Adding a field to a model | the five `constant.ts` classes, the dictionary entry, the store form, `akan sync` |
|
|
233
|
-
| 2. Injecting a dependency into a service | `service<srv.XService>()`, `plug(Adapter)`, `env(...)`, `use<T>()` |
|
|
233
|
+
| 2. Injecting a dependency into a service | `service<srv.XService>()`, `plug(Adapter)`, `env(...)`, `use<T>()`, writing an `adapt()` adapter |
|
|
234
234
|
| 3. Creating and using a slice | `slice()` guards, the generated `init<Model><Suffix>`, `Load.Units` hydration |
|
|
235
235
|
| 4. Creating a mutation endpoint | the document chain method, the service call, the signal `guards`, the store action |
|
|
236
|
-
| 5. Internal triggers | `internal()` with interval and cron |
|
|
236
|
+
| 5. Internal triggers | `internal()` with interval and cron, queue retries and the dead letter |
|
|
237
237
|
| 6. Creating an insight | the `XInsight` class, the aggregation, the dashboard tile |
|
|
238
238
|
| Data Flow Summary | how a value travels page → store → signal → service → document → DB |
|
|
239
239
|
| Auto-Generated API Reference | what signal, service, store, and document each generate — never hand-write these |
|
|
@@ -244,7 +244,7 @@ existing content first and change only the relevant sections — never rewrite a
|
|
|
244
244
|
## Modeling & Query Gotchas
|
|
245
245
|
|
|
246
246
|
A short list of things the type system does not always catch. The full rules for text search, `cascade`, and the
|
|
247
|
-
query-level writes are in
|
|
247
|
+
query-level writes are in `queryRule`; these are the shapes that build green and fail later.
|
|
248
248
|
|
|
249
249
|
- **Slices return a query, not a list.** A slice `exec` must return `this.<model>Service.query<Filter>(...)`, never
|
|
250
250
|
a `list<Filter>(...)` / `listBy...(...)` array. Returning an array type-checks but fails at runtime with
|
|
@@ -258,7 +258,7 @@ query-level writes are in the convention set above; these are the shapes that bu
|
|
|
258
258
|
**bare** — `this.userModel.pickById(id, { passwordHash: true })`, likewise `pickOne` / `find` / `findOne` /
|
|
259
259
|
`findById`. A generated filter accessor takes it **inside the option object** —
|
|
260
260
|
`this.userModel.findByAccountId(accountId, statuses, { select: { passwordHash: true } })`. Handing `{ select: … }`
|
|
261
|
-
to the facade
|
|
261
|
+
to the facade projects a field named `select`, which no model has, and throws `Unknown document field path`.
|
|
262
262
|
- **`cascade` names a direction, and the wrong one is a data loss** — `removeRef` on the relation the owner holds,
|
|
263
263
|
`removeWith` on the child's reference to its owner. A query-level removal fires no hooks and therefore no cascade.
|
|
264
264
|
- **`q.search()` is a filter node, not a slice requirement.** `bySearch: filter().arg("text", String).query((text,
|
|
@@ -66,11 +66,12 @@ const form = st.use.taskForm();
|
|
|
66
66
|
|
|
67
67
|
### Recipe 2: Injecting a Dependency into a Service
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
registered under `<refName>` — the `Service`/`Signal` suffix is required
|
|
69
|
+
Four patterns: injecting an **external adapter** (`use<>()`), another **module's service** (`service<>()`),
|
|
70
|
+
a **predefined framework adapter** (`plug()`), or **an adapter you own** (`adapt()` + `plug()`). A field named
|
|
71
|
+
`<refName>Service` resolves to the service registered under `<refName>` — the `Service`/`Signal` suffix is required
|
|
72
|
+
and stripped to derive the lookup key.
|
|
72
73
|
|
|
73
|
-
> **For a new adapter you own, prefer the `adapt()` shape in pattern
|
|
74
|
+
> **For a new adapter you own, prefer the `adapt()` shape in pattern D over the `option.ts` registration in
|
|
74
75
|
> pattern A.** An `adapt()` class self-registers and is injected with `plug(TheClass)`, so it never touches
|
|
75
76
|
> `option.ts`. Pattern A is the legacy constructor-style shape: recognise it, keep it working, and migrate one
|
|
76
77
|
> only when you are already changing it.
|
|
@@ -141,12 +142,64 @@ export class TaskService extends serve(db.task, ({ plug }) => ({
|
|
|
141
142
|
}
|
|
142
143
|
```
|
|
143
144
|
|
|
144
|
-
|
|
145
|
+
To reach the file itself (a `Bun.file()` response with ranges, a CLI tool, a size check), ask the role where it
|
|
146
|
+
lives: `this.storage.resolveLocalPath?.(path, access)` (`access` optional) answers the absolute disk path
|
|
147
|
+
`BlobStorage` uses, picking the root as its writes do with `access` and by the `private/` prefix without it. It
|
|
148
|
+
answers `null` for a key that is rooted, holds a NUL, or leaves that root — refuse that key, never fall back to
|
|
149
|
+
`readData()` for it. Fall back only when the adaptor has no `resolveLocalPath` at all (object storage). **A key
|
|
150
|
+
that reaches you from a request passes `access: "public"`**: without it a `private/` key resolves under the private
|
|
151
|
+
root, so a public route would serve private files. Never cast the role to reach `root` / `privateRoot`.
|
|
152
|
+
|
|
153
|
+
For a custom adapter class (not a predefined role), pass the class itself, e.g. `ipfsApi: plug(IpfsApi)`. Two framework
|
|
154
|
+
adapters are plugged that way: `rateLimit: plug(RateLimit)` to count a business key, and
|
|
155
|
+
`endpointCache: plug(EndpointCacheControl)` to drop an endpoint's `{ cache }` answers after the write that made them
|
|
156
|
+
stale: `await this.endpointCache.invalidate("bannerListInPublic")`, plus `"bannerInsightInPublic"` for a slice's
|
|
157
|
+
count.
|
|
145
158
|
Injecting a file/image field is usually simpler than calling
|
|
146
159
|
storage directly: declare `image: field(File).optional()` (or `images: field([File])`) on the model and let the
|
|
147
160
|
store's generated `upload<Field>On<Model>(fileList)` action handle the upload. Add `{ cascade: "removeRef" }` to that
|
|
148
161
|
field when the file belongs to the model alone, and removing the model removes the file and its stored object.
|
|
149
162
|
|
|
163
|
+
**D. An adapter you own — `adapt()` in `srvkit/`**
|
|
164
|
+
|
|
165
|
+
An injected singleton is an `adapt()` class in `srvkit/`. Write new adapters this way.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
export class AdminNoti extends adapt("adminNoti" as const, ({ use, env, plug, memory }) => ({
|
|
169
|
+
discordApi: use<DiscordApi>(),
|
|
170
|
+
workspaceRoot: env(() => `~/build/${getEnv().environment}`),
|
|
171
|
+
masterHost: plug(MasterHost),
|
|
172
|
+
tokenMap: memory(Map, { of: String }),
|
|
173
|
+
})) {}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- Inject it with `plug(TheClass)` from a service or from another adapter. Destructure only the injectors you use,
|
|
177
|
+
and write the registration key `as const`.
|
|
178
|
+
- **Do not register an `adapt()` class in `lib/option.ts`.** It self-registers, and `plug(Class)` uses the class
|
|
179
|
+
itself as the token. `option.ts` is now only for legacy constructor-style adapters and for widening the options
|
|
180
|
+
type.
|
|
181
|
+
- `this.logger` is provided; never construct a `Logger` inside an `adapt()` class. Lifecycle work goes in
|
|
182
|
+
`override async onInit()`.
|
|
183
|
+
- **`adapt()` is for singletons only.** A per-use value object stays a plain class you `new` at the call site. If
|
|
184
|
+
there is not exactly one per process that a service needs injected, it is a plain class.
|
|
185
|
+
- **`memory(...)` takes a scalar or model class, not only a primitive** — `memory(Map, { of: CallStateInput })`
|
|
186
|
+
serializes through the constant and travels as JSON text, so the Redis and sqlite-backed caches round-trip the
|
|
187
|
+
same declaration. Never hand-encode JSON into a `String` memory. A memory is scoped to the service or adaptor
|
|
188
|
+
that declares it; `ttl` (milliseconds) sets how long each write lives unless the `set` call names its own
|
|
189
|
+
`expireAt`, and a `Map` entry expires on its own.
|
|
190
|
+
|
|
191
|
+
Conventions that hold for both adapter shapes (A and D):
|
|
192
|
+
|
|
193
|
+
- Route every remote call through one private `#api<T>(path, init?)` with `signal: AbortSignal.timeout(20_000)`.
|
|
194
|
+
- Paginate with `for (let page = 1; ; page += 1)` broken by `if (pageItems.length < 100) break;`.
|
|
195
|
+
- Resolve secrets as `process.env.X ?? options.x ?? deterministicGenerator(...)` **inside a function**, never at
|
|
196
|
+
module scope.
|
|
197
|
+
- Extend a function by appending an optional trailing parameter with a default, never by changing arity.
|
|
198
|
+
- Parameters: up to three required primitives positional; optional flags in a trailing `{ … } = {}`; four or more
|
|
199
|
+
parameters, or any two same-typed strings, in one named destructured object.
|
|
200
|
+
- Release locks in `finally`. Load heavy optional dependencies through a module-level memoized promise
|
|
201
|
+
(`puppeteerLoad ??= import("puppeteer")`).
|
|
202
|
+
|
|
150
203
|
---
|
|
151
204
|
|
|
152
205
|
### Recipe 3: Creating and Using a Slice
|
|
@@ -245,7 +298,11 @@ async startTask(taskId: string) {
|
|
|
245
298
|
|
|
246
299
|
// 6. <Model>.Util.tsx — Reusable button component
|
|
247
300
|
export const Start = ({ taskId }: { taskId: string }) => (
|
|
248
|
-
<button
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
className={buttonRecipe({ variant: "primary", size: "xs" })}
|
|
304
|
+
onClick={() => st.do.startTask(taskId)}
|
|
305
|
+
>
|
|
249
306
|
Start
|
|
250
307
|
</button>
|
|
251
308
|
);
|
|
@@ -285,6 +342,10 @@ async cleanupStaleTasks() {
|
|
|
285
342
|
- `initialize()` — runs once on service startup
|
|
286
343
|
- `process(Return).msg(Type)` — message queue consumer
|
|
287
344
|
|
|
345
|
+
A `process` job's `backoff: { type: "exponential", delay, maxDelay }` caps the wait between attempts, and the
|
|
346
|
+
internal's `onFailed(job, error)` runs once a job has failed for good — its last attempt, or bullmq's stall limit —
|
|
347
|
+
the dead letter, on either queue.
|
|
348
|
+
|
|
288
349
|
---
|
|
289
350
|
|
|
290
351
|
### Recipe 6: Creating an Insight (Aggregation / Dashboard Stats)
|
|
@@ -492,7 +553,7 @@ akan sync automatically generates APIs across all layers. Only write custom logi
|
|
|
492
553
|
| Auto-Generated | Description |
|
|
493
554
|
|---------------|-------------|
|
|
494
555
|
| `this.<model>Model` | Auto-injected model adaptor |
|
|
495
|
-
| `get<Model>(id)`, `load<Model>(id)` | Single document lookup |
|
|
556
|
+
| `get<Model>(id)`, `load<Model>(id)` | Single document lookup: `get` throws `NoDocumentError` for a missing or removed id, answered 404 `base.error.documentNotFound`; `load` returns `null` |
|
|
496
557
|
| `create<Model>(data)`, `update<Model>(id, data)`, `remove<Model>(id)` | CRUD operations — named after the model, e.g. `createTask`/`updateTask`/`removeTask` (there is no literal `createModel`) |
|
|
497
558
|
| `list<Query>(args)`, `find<Query>(args)`, `pick<Query>(args)` | Filter-based queries |
|
|
498
559
|
| `exists<Query>(args)`, `count<Query>(args)`, `insight<Query>(args)` | Filter-based helpers |
|