@akanjs/cli 3.0.0-alpha.10 → 3.0.0-alpha.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.build-stamp +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/{abstractCompactor-e5yq60qz.js → abstractCompactor-byytxs22.js} +3 -5
- package/{agent.command-f0gqkfgz.js → agent.command-v1a4746v.js} +11 -13
- package/{application.command-x903m2qq.js → application.command-fzdcnzhg.js} +65 -38
- package/{applicationBuildRunner-ha3y8ew8.js → applicationBuildRunner-s4gsk728.js} +27 -23
- package/{applicationReleasePackager-gg8ccbeh.js → applicationReleasePackager-28v9vbzk.js} +8 -9
- package/buildBatch.proc.js +12 -14
- package/capacitorApp-8d6d4zkm.js +12 -0
- package/{cloud.command-zs6kyf83.js → cloud.command-wygy2z05.js} +15 -18
- package/commandManifest.json +1 -1
- package/{context.command-fmepgjzg.js → context.command-4vdt1aqq.js} +19 -23
- package/dependencyScanner-shs82ynp.js +8 -0
- package/devTui-qnf1txdz.js +553 -0
- package/devTuiView-yzct2cq6.js +9 -0
- package/getDirname-dr3jz1xg.js +7 -0
- package/{guideline.command-n6sw0a0c.js → guideline.command-p92dq6ts.js} +16 -18
- package/guidelines/agentRule/agentRule.generate.json +31 -0
- package/guidelines/agentRule/agentRule.instruction.md +533 -0
- package/guidelines/conventions/conventions.instruction.md +437 -197
- package/guidelines/cssRule/cssRule.instruction.md +7 -0
- package/guidelines/framework/framework.generate.json +3 -2
- package/guidelines/framework/framework.instruction.md +1 -9
- package/guidelines/mcpRule/mcpRule.generate.json +25 -0
- package/guidelines/mcpRule/mcpRule.instruction.md +217 -0
- package/guidelines/modelStore/modelStore.instruction.md +12 -0
- package/guidelines/modelTemplate/modelTemplate.instruction.md +5 -0
- package/guidelines/queryRule/queryRule.generate.json +31 -0
- package/guidelines/queryRule/queryRule.instruction.md +175 -0
- package/guidelines/runtimeRule/runtimeRule.generate.json +25 -0
- package/guidelines/runtimeRule/runtimeRule.instruction.md +220 -0
- package/guidelines/ssrRule/ssrRule.instruction.md +49 -5
- package/guidelines/transportRule/transportRule.generate.json +31 -0
- package/guidelines/transportRule/transportRule.instruction.md +60 -0
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +51 -760
- package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +33 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +402 -0
- package/incrementalBuilder.proc.js +26 -25
- package/{index-jf2ya7r2.js → index-0amdxycf.js} +8 -8
- package/{index-779ske1y.js → index-1yxtbmbj.js} +27 -27
- package/{index-eyyf2149.js → index-29sxqfaw.js} +36 -37
- package/index-3d9y066c.js +76 -0
- package/{index-76rn3g2c.js → index-47pj3aee.js} +4 -4
- package/index-5svjba73.js +159 -0
- package/{index-1577bej2.js → index-6wwzs1bc.js} +2 -2
- package/{index-gcv8ncfh.js → index-7d6vny25.js} +16 -12
- package/{index-y4vkyhvv.js → index-8p8p0cf8.js} +14 -14
- package/{index-wnp7hwq7.js → index-92zztqy1.js} +7 -7
- package/{index-fgc8r6dj.js → index-a5vmm6x4.js} +3 -1
- package/{index-xys926f2.js → index-by5gpp8x.js} +4 -4
- package/{index-0cj2zxbm.js → index-c69j78kk.js} +12 -11
- package/{index-rxkpk7n4.js → index-fc1bh0a7.js} +4 -4
- package/{index-d43t8af2.js → index-gq44bqjt.js} +58 -25
- package/{index-774gcx29.js → index-gtmz74tj.js} +41 -34
- package/{index-j37qq1f2.js → index-h7yw2z9n.js} +7 -7
- package/{index-bjpxzr6s.js → index-hwkygn9s.js} +51 -3
- package/{index-xmmzf150.js → index-hyywke0f.js} +1 -1
- package/{index-46tjzh6s.js → index-kcxmv48c.js} +29 -29
- package/{index-xjd3rqzj.js → index-n68jx79w.js} +6 -15
- package/{index-qb61h0k7.js → index-p5neav9b.js} +1098 -1360
- package/{index-67546d0j.js → index-q0y2jq9j.js} +14 -2
- package/{index-3vta1tqa.js → index-qkk27bk6.js} +32 -32
- package/{index-z8ptfkzv.js → index-qsq3q8fy.js} +975 -242
- package/{index-qzdjd4ct.js → index-srdssqj7.js} +18 -18
- package/{index-j9cxndc0.js → index-w8kmx1b6.js} +20 -11
- package/{index-82jrh4hj.js → index-yks023mb.js} +13 -13
- package/{index-5sn5w2mt.js → index-yza7zkpt.js} +94 -61
- package/{index-jhpa13zj.js → index-zmjppp25.js} +57 -57
- package/{index-kdh76j26.js → index-znynzt5w.js} +100 -32
- package/{index-4m0bphac.js → index-zxgr3s8s.js} +31 -30
- package/{index-62955kn5.js → index-zz5jyd94.js} +1002 -128
- package/index.js +22 -25
- package/{library.command-cv9gdbmg.js → library.command-s4fszngs.js} +11 -10
- package/{localRegistry.command-d8fg36zc.js → localRegistry.command-gyw4da9z.js} +20 -23
- package/{module.command-qyr1s9cn.js → module.command-6n7dwvav.js} +13 -16
- package/{package.command-wsjx0k47.js → package.command-gr82ypnr.js} +8 -10
- package/package.json +5 -7
- package/{page.command-rdzg65rm.js → page.command-7gvgb3jb.js} +7 -9
- package/{primitive.command-89g38b53.js → primitive.command-tk5ckw38.js} +13 -16
- package/{quality.command-0cs2z92d.js → quality.command-38v81baf.js} +226 -105
- package/recipeScanner-fpagtgc1.js +11 -0
- package/{repair.command-6fphxfwj.js → repair.command-5xbwvsxp.js} +12 -14
- package/{routeSourceValidator-e4jmbfcx.js → routeSourceValidator-ze6x894c.js} +0 -2
- package/{scalar.command-xfer69s6.js → scalar.command-0epwcr5k.js} +11 -14
- package/subspace.command-e2t36xb2.js +755 -0
- package/templates/app/env/env.client.debug.ts.template +1 -5
- package/templates/app/env/env.client.develop.ts.template +1 -5
- package/templates/app/env/env.client.local.ts.template +1 -5
- package/templates/app/env/env.client.main.ts.template +1 -5
- package/templates/app/env/env.client.testing.ts.template +1 -5
- package/templates/app/env/env.client.type.ts +2 -6
- package/templates/app/env/env.server.debug.ts.template +1 -5
- package/templates/app/env/env.server.develop.ts.template +1 -5
- package/templates/app/env/env.server.local.ts.template +1 -5
- package/templates/app/env/env.server.main.ts.template +1 -5
- package/templates/app/main.ts +3 -1
- package/templates/appSample/lib/task/task.signal.ts +11 -6
- package/templates/appSample/srvkit/AuthGuard.ts +9 -5
- package/templates/appSample/srvkit/SessionInternalArg.ts +4 -3
- package/templates/crudPages/[__model__Id]/edit/page.tsx +5 -5
- package/templates/crudPages/[__model__Id]/page.tsx +10 -13
- package/templates/crudPages/new/page.tsx +5 -5
- package/templates/crudPages/page.tsx +9 -9
- package/templates/crudSinglePage/page.tsx +11 -7
- package/templates/env/_env.server.type.ts +7 -5
- package/templates/lib/db.ts +1 -1
- package/templates/lib/dict.ts +1 -1
- package/templates/lib/sig.ts +5 -1
- package/templates/lib/srv.ts +6 -2
- package/templates/module/__model__.dictionary.ts +4 -1
- package/templates/module/__model__.signal.ts +2 -3
- package/templates/module/__model__.store.ts +1 -2
- package/templates/workspaceRoot/.gitignore.template +0 -1
- package/templates/workspaceRoot/.husky/pre-commit.template +16 -0
- package/templates/workspaceRoot/.vscode/settings.json.template +1 -1
- package/templates/workspaceRoot/AGENTS.md.template +4 -0
- package/templates/workspaceRoot/CLAUDE.md.template +19 -0
- package/templates/workspaceRoot/biome.json.template +2 -1
- package/templates/workspaceRoot/docs/AI-DEVELOPMENT.md.template +10 -14
- package/templates/workspaceRoot/docs/GENERATED.md.template +12 -10
- package/templates/workspaceRoot/package.json.template +2 -4
- package/typeChecker-3xgrzc2v.js +7 -0
- package/typecheck.proc.js +4 -5
- package/{workflow.command-2ygfqt4f.js → workflow.command-kphbr7eh.js} +22 -25
- package/{workspace.command-vbgpjkf9.js → workspace.command-j1gatz3d.js} +96 -81
- package/capacitorApp-9xswcgx7.js +0 -61
- package/dependencyScanner-f6grrep3.js +0 -9
- package/getDirname-m9s0cmhq.js +0 -8
- package/index-mxvakhsm.js +0 -66
- package/index-r24hmh0q.js +0 -4
- package/recipeScanner-tghkzrrt.js +0 -12
- package/typeChecker-kravn7ns.js +0 -8
- /package/{index-0wae5ebk.js → index-meh558jy.js} +0 -0
- /package/{index-ss469dec.js → index-nz8prx9g.js} +0 -0
|
@@ -55,6 +55,13 @@ Screen code is invariant — a plain `<Button variant="primary">` never changes.
|
|
|
55
55
|
- Switching themes is toggling the `data-theme` attribute. Keep every token defined in both the dark and light blocks.
|
|
56
56
|
- Radius uses `rounded-box` (cards/modals), `rounded-field` (buttons/inputs), `rounded-selector` (toggles/checkboxes).
|
|
57
57
|
|
|
58
|
+
## Lib-Owned Tokens (`libs/<lib>/ui/tokens.css`)
|
|
59
|
+
- A lib that needs colors of its own — vendor brand colors pinned by someone else's guide (Kakao `#fee500`, Naver `#1ec800`), fixed in-game surfaces — declares them **once** in `libs/<lib>/ui/tokens.css`. Never copy the block into each app's stylesheet.
|
|
60
|
+
- The file is collected automatically: every app whose page graph reaches that lib compiles it, ordered **ahead of** the app's own stylesheets, so the app wins on any variable both declare. There is nothing to import and no sync step to remember; two libs both declaring one variable resolve in lib-name order, so do not rely on that.
|
|
61
|
+
- Declare plain custom properties under `:root`, not a Tailwind `@theme` extension: the color vocabulary is closed per stylesheet, so `bg-kakao` would generate no CSS. Reference them as `bg-[var(--kakao)]` / `text-[var(--kakao-foreground)]`, which `no-arbitrary-color` allows by design (a `var()` reference is not an arbitrary color literal).
|
|
62
|
+
- Theme-following colors still belong to the app: a lib token is for a color that must **not** change with `data-theme`.
|
|
63
|
+
- An `@import` the CSS pipeline cannot resolve fails the build; it is never dropped. A stylesheet under `page/` that no route imports is compiled by nothing and warns at build time.
|
|
64
|
+
|
|
58
65
|
## Codegen Rules
|
|
59
66
|
- Do not hardcode hex or raw-palette colors (`bg-red-500`, `#ff0000`); use semantic tokens.
|
|
60
67
|
- Do not put inline color literals in `style={{ }}`; use token classes.
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
],
|
|
21
21
|
"update": {
|
|
22
22
|
"filePath": "./framework.instruction.md",
|
|
23
|
-
"contents": ["
|
|
23
|
+
"contents": ["file ownership", "current API patterns", "codegen rules", "theming and UI customization"],
|
|
24
24
|
"rules": [
|
|
25
25
|
"Use current Akan APIs only",
|
|
26
26
|
"Keep output parseable for codegen",
|
|
27
|
-
"Include ownership boundaries
|
|
27
|
+
"Include ownership boundaries",
|
|
28
|
+
"Carry no meta about this document itself — it renders into every workspace's always-loaded AGENTS.md"
|
|
28
29
|
]
|
|
29
30
|
},
|
|
30
31
|
"page": "apps/akan/page/(docs)/docs/intro/fundamentals.tsx"
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# Akan.js Framework Guide
|
|
2
2
|
|
|
3
|
-
## Purpose
|
|
4
|
-
Use this as the compact framework context for AI codegen. It should explain how Akan turns convention-based files into a full-stack app without teaching every API in one place.
|
|
5
|
-
|
|
6
3
|
## Ownership
|
|
7
4
|
- `apps/<app>` contains app code, pages, env files, module folders, UI, webkit, srvkit, and common utilities.
|
|
8
5
|
- `libs/<lib>` contains reusable domain and utility libraries consumed by apps.
|
|
@@ -26,10 +23,5 @@ Use this as the compact framework context for AI codegen. It should explain how
|
|
|
26
23
|
When a request implies a distinct look and feel, do not stop at colors — customize both the theme and, when needed, the components.
|
|
27
24
|
|
|
28
25
|
- **Theme (`apps/<app>/page/styles.css`).** The app imports Tailwind and `akanjs/ui/styles.css`, then overrides semantic token *values* per theme under `:root, [data-theme="dark"]` and `[data-theme="light"]` (`--background`, `--foreground`, `--primary`, `--muted`, `--border`, … each with a `-foreground` pair for text). The framework maps them to Tailwind color names, so `bg-primary` / `text-foreground` follow the `data-theme` attribute; corner rounding uses `--radius-box` / `--radius-field`. Fetch `get_guideline` with `cssRule` for the full token set before a deep theme pass.
|
|
26
|
+
- **Lib tokens (`libs/<lib>/ui/tokens.css`).** Colors a lib's own components pin — a vendor brand color, a fixed surface — are declared once there as plain `:root` custom properties and compiled into every app that reaches the lib, ahead of the app's stylesheets. Reference them as `bg-[var(--kakao)]`; never copy the block into each app.
|
|
29
27
|
- **Components (`page/**/_overrides.tsx`).** When a default `akanjs/ui` component (Button, Modal, Table, Input, Select, …) is too restrictive for the design, re-skin it per route instead of forking, wrapping, or fighting it with utility classes. Write a drop-in replacement in `apps/<app>/ui/` typed against the slot contract (`AkanModalComponent`, or `AkanUiOverrides["<Slot>"]`), composing the framework's headless parts, then bind it in a `page/**/_overrides.tsx` manifest with a single `export default override({ Slot: BrandComponent })`. Overrides cascade down the route tree like layouts (closest ancestor wins). Fetch `get_guideline` with `componentRule` and read the `references/ui/customize` docs page for the slot list and patterns.
|
|
30
|
-
|
|
31
|
-
## Review Checklist
|
|
32
|
-
- The instruction points to current docs pages, not removed docs routes.
|
|
33
|
-
- Generated examples use current Akan builder APIs and scanner-friendly filenames.
|
|
34
|
-
- The output contract tells the model which file paths to return.
|
|
35
|
-
- The guide avoids broad framework essays when a concrete file rule is better.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "MCP Exposure Rule",
|
|
3
|
+
"description": "How Akan signals reach AI agents over MCP — guard-derived exposure, refusals, prompts, and resources",
|
|
4
|
+
"group": "core",
|
|
5
|
+
"codegenPriority": 4,
|
|
6
|
+
"deprecated": false,
|
|
7
|
+
"scans": [
|
|
8
|
+
{
|
|
9
|
+
"type": "example",
|
|
10
|
+
"description": "Signal files whose slices and endpoints declare the guards MCP exposure is derived from",
|
|
11
|
+
"path": "{apps,libs}/*/lib/*/*.signal.ts",
|
|
12
|
+
"sample": 4
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"update": {
|
|
16
|
+
"filePath": "./mcpRule.instruction.md",
|
|
17
|
+
"contents": ["exposure rule", "configuration", "declaring endpoints", "refusals and wire behaviour", "prompts"],
|
|
18
|
+
"rules": [
|
|
19
|
+
"Use current Akan APIs only",
|
|
20
|
+
"State the rule before the reason, and keep the reason to one clause",
|
|
21
|
+
"Name the env spelling beside every option field",
|
|
22
|
+
"Keep it workspace-neutral — no app or repo names"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Akan MCP Exposure Guideline
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
Use this when writing a `*.signal.ts`, configuring `option.setMcp(...)`, or debugging a tool an agent cannot see.
|
|
5
|
+
The always-loaded convention set carries the short version; this is the full contract.
|
|
6
|
+
|
|
7
|
+
## Exposure Rule
|
|
8
|
+
|
|
9
|
+
Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
|
|
10
|
+
follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
|
|
11
|
+
that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
|
|
12
|
+
`Public`. `AKAN_MCP=false` takes the whole surface off. The reasoning is that the guards are already the
|
|
13
|
+
authorization decision and `filterForAccount` re-reads them per caller on every listing, so a second per-endpoint
|
|
14
|
+
switch says nothing the guards do not — while guaranteeing that every endpoint added later is invisible to agents
|
|
15
|
+
until somebody remembers it.
|
|
16
|
+
|
|
17
|
+
## Keeping An Endpoint Off The Shelf — `mcp: false`
|
|
18
|
+
|
|
19
|
+
Guards answer "may an agent call this". They do not answer "does this belong on a shelf", and those are different
|
|
20
|
+
questions: a step of a UI-driven state machine (`requestPhoneCodeForSignin`, `setPasswordInPrepareUser`) is
|
|
21
|
+
perfectly guarded, perfectly callable, and a model would only ever call it by mistake. **`mcp: false` is that
|
|
22
|
+
second answer, and it is an opt-*out*: everything still publishes by default, so an endpoint added later is
|
|
23
|
+
visible without anyone remembering it.**
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
requestPhoneCodeForSignin: mutation(Boolean, { guards: [Every], mcp: false }),
|
|
27
|
+
inTodo: init({ guards: [SignedIn], mcp: false }), // the slice's list and insight together
|
|
28
|
+
export class FileSlice extends slice(srv.file, {
|
|
29
|
+
guards: { root: Admin, get: SignedIn, cru: SignedIn },
|
|
30
|
+
mcp: { cru: false }, // reads yes, generated writes no
|
|
31
|
+
}) {}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **It is curation, not authorization.** HTTP serves the endpoint exactly as before and the guards are still the
|
|
35
|
+
only thing deciding who may call it. Never write `mcp: false` where you meant a guard.
|
|
36
|
+
- **The `mcp` map on `slice()` mirrors the `guards` map above it** — same keys (`root`, `get`, `cru`, `create`,
|
|
37
|
+
`update`, `remove`), same fallbacks (`create`/`update`/`remove` inherit `cru`), and the same scope: it narrows
|
|
38
|
+
the root slice and the generated CRUD, and never reaches a named slice or a custom endpoint. Those declare
|
|
39
|
+
their own `mcp` beside their own `guards`. The bare `mcp: false` is every key at once, still within that scope.
|
|
40
|
+
- **`mcp: true` is the default and is a no-op**; write it only to say out loud that an entry is deliberately on
|
|
41
|
+
the shelf. It does not override a shape refusal (an `Any` return stays refused) and it cannot publish something
|
|
42
|
+
the guard rules keep out.
|
|
43
|
+
- **Reach for it by cost, not by taste.** Every entry inlines the schema of every model it mentions, so a plain
|
|
44
|
+
21-field model with one named slice costs 12.6KB across its eight entries. `mcp: { cru: false }` on that model
|
|
45
|
+
takes it to 4.7KB — the write entries carry both the input *and* the full model, and are 63% of it.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
|
|
49
|
+
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, and `auth`. **Not `main.ts`**: the gateway
|
|
50
|
+
there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
|
|
51
|
+
mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
|
|
52
|
+
declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
|
|
53
|
+
`AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
|
|
54
|
+
`AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a
|
|
55
|
+
deployment that configures what the source does not, which the option overrides.
|
|
56
|
+
The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
|
|
57
|
+
has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
|
|
58
|
+
`AKAN_MCP_PATH` is normalized to a leading `/`, because the route key and the OAuth metadata path are both built by
|
|
59
|
+
concatenation.
|
|
60
|
+
|
|
61
|
+
## Declaring Endpoints
|
|
62
|
+
```typescript
|
|
63
|
+
// apps/<app>/lib/option.ts
|
|
64
|
+
export const option = new AkanOption<ModulesOptions>().setMcp({
|
|
65
|
+
instructions: "Domain tools for this app. Start from taskInTodo.",
|
|
66
|
+
language: "en",
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
// <model>.signal.ts — every one of these is an MCP tool or prompt, with no `mcp:` option anywhere
|
|
72
|
+
export class TaskSlice extends slice(
|
|
73
|
+
srv.task,
|
|
74
|
+
{ guards: { root: Admin, get: SignedIn, cru: SignedIn } },
|
|
75
|
+
(init) => ({
|
|
76
|
+
// its own guards: the map above reaches base CRUD and the root slice, never a named slice — so a named slice
|
|
77
|
+
// that names none is refused rather than published, which is the one shape to watch for.
|
|
78
|
+
inTodo: init({ guards: [SignedIn] }).exec(function () {
|
|
79
|
+
return this.taskService.queryByStatuses(["todo"]);
|
|
80
|
+
}),
|
|
81
|
+
}),
|
|
82
|
+
) {}
|
|
83
|
+
|
|
84
|
+
export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => ({
|
|
85
|
+
startTask: mutation(cnst.Task, { guards: [SignedIn] })
|
|
86
|
+
.param("taskId", ID)
|
|
87
|
+
.exec(async function (taskId) {
|
|
88
|
+
return await this.taskService.startTask(taskId);
|
|
89
|
+
}),
|
|
90
|
+
reviewTask: prompt({ guards: [SignedIn] })
|
|
91
|
+
.param("taskId", ID)
|
|
92
|
+
.exec(async function (taskId) {
|
|
93
|
+
const task = await this.taskService.getTask(taskId);
|
|
94
|
+
return [Msg.user(`Review this task and suggest next steps.`), Msg.resource(`akan://task/${taskId}`, task)];
|
|
95
|
+
}),
|
|
96
|
+
})) {}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Refusals And Wire Behaviour
|
|
100
|
+
- **`mcp: false` is read first**, because it is the one answer nobody had to derive — somebody stated it. Its
|
|
101
|
+
reason says "HTTP still serves it", because that is the sentence that stops the next reader treating it as a lock.
|
|
102
|
+
- **The refusals are fail-closed**: **an endpoint that declares no `guards` at all** (nobody decided who may reach
|
|
103
|
+
it), **a mutation with no real `guards`** (`[Public]` is having none, spelled out — it answers true
|
|
104
|
+
unconditionally), `pubsub` and `message` (their internal args read a socket an MCP request does not have), an
|
|
105
|
+
`Any` or `Upload` return, a file upload, **an argument typed `Any` that must be filled**, and the generated
|
|
106
|
+
**`light<Model>` read** — it returns the same document as `<Model>` under the same guards, in a shape trimmed
|
|
107
|
+
for a page's payload, so publishing both put two entries per model in a listing an agent pays for every turn
|
|
108
|
+
and gave it nothing but the word "light" to choose between them.
|
|
109
|
+
A `prompt` refuses two more, because its `arguments` is one string per name with no schema beside it: a **list
|
|
110
|
+
argument**, which could never carry a second value, and **any `Any` argument** — a tool leaves that out of its
|
|
111
|
+
schema, and a prompt has no schema to leave it out of.
|
|
112
|
+
- **The boot log sizes the catalogue and says which signals it went to** — `MCP catalogue: tools=48 … · listing
|
|
113
|
+
214KB` and `MCP catalogue cost: user 27/71KB · file 6/22KB · …`, heaviest first. Read it before narrowing
|
|
114
|
+
anything: the number is nobody's intuition, because MCP has no shared component section and forbids a `$ref`
|
|
115
|
+
across entries, so **every entry inlines the full schema of every model it mentions** and a listing is re-sent
|
|
116
|
+
whole to every agent that connects, before its first turn. Past 100KB it says so as a `warn`.
|
|
117
|
+
- **Every refusal is named in the boot log**: one `warn` per endpoint plus a `MCP catalogue: tools=… prompts=…`
|
|
118
|
+
count. Read that line first when a tool you expected is missing — and it is the *only* place the answer exists,
|
|
119
|
+
because there is no absent opt-in to notice. The API explorer badges the same rule per endpoint (`MCP` /
|
|
120
|
+
`MCP refused`), from the same shared implementation the catalogue runs.
|
|
121
|
+
- **An `Any` argument is left out of the published schema** rather than described as `{}` — it tells a model
|
|
122
|
+
nothing — and a value sent for one is refused by name, so the endpoint reads it as omitted. That is what happens
|
|
123
|
+
to the root list's raw `query` descriptor: read as sent, it would be an arbitrary filter over every model you
|
|
124
|
+
publish. Declare a named filter slice when an agent should narrow a list.
|
|
125
|
+
- **A nullable model return publishes no `outputSchema`**, and its empty answer ships as the text `null` with no
|
|
126
|
+
`structuredContent`. That field is an object by definition, so `null` cannot ride in it any more than an array
|
|
127
|
+
can — a list is wrapped as `{ items: … }` for the same reason — and a declared schema obliges every result to
|
|
128
|
+
match it, so a client SDK throws on the first call that finds nothing. A nullable *list* keeps its schema, and a
|
|
129
|
+
scalar return has no structured half at all: it ships as the value itself, not as JSON.
|
|
130
|
+
- **An `outputSchema` names no `hidden`, `secret`, or `visual` field.** Every response has the first two stripped,
|
|
131
|
+
so publishing them promises a property no answer can carry — and on a model like `user` the names are the leak.
|
|
132
|
+
Your *input* schema keeps all three: they are legal to send, and the same model describes a request body.
|
|
133
|
+
- **A `field.visual` is stripped from every MCP result.** It is the marker for a field the page renders and no
|
|
134
|
+
question is answered from — a blur placeholder, a rendered HTML body — and it is cost rather than secrecy, so
|
|
135
|
+
nothing is refused over one. The strip happens in the MCP dispatcher rather than in `resolveReturn`, which every
|
|
136
|
+
ordinary HTTP response also passes through: the whole point is that a browser still receives it. That is also why
|
|
137
|
+
the readable schema drops it — a non-optional visual field would otherwise be listed `required` and a validating
|
|
138
|
+
client would refuse a result that correctly omits it.
|
|
139
|
+
- **A structured result ships twice by default**, once as `structuredContent` and once as the same JSON in the text
|
|
140
|
+
block, which is what the spec asks of a server for clients predating the structured field. It is also a flat
|
|
141
|
+
doubling of what every model-returning tool costs a model. `option.setMcp({ legacyTextBlock: false })` — or
|
|
142
|
+
`AKAN_MCP_LEGACY_TEXT=false` — leaves a one-line pointer in the text block instead, for a deployment whose
|
|
143
|
+
clients read the structured half. A scalar return is unaffected: it has no structured half to point at, so it
|
|
144
|
+
ships as the value itself either way.
|
|
145
|
+
- A refused endpoint answers the *same* "unknown tool" as one that does not exist. Never make that
|
|
146
|
+
message more helpful — the difference is what enumerates your private surface. A guard's refusal is generalized
|
|
147
|
+
the same way: the caller reads `You are not permitted to perform this action.`, never `Access denied by guard:
|
|
148
|
+
Admin`, which names your authorization structure to the one caller barred from it. A domain `Err` resolves
|
|
149
|
+
through the dictionary first and keeps its own words.
|
|
150
|
+
- The `readOnly` / `destructive` / `idempotent` hints a client renders are derived from the endpoint type and key
|
|
151
|
+
and are not configurable. Clients are told to distrust hints; they are never a gate.
|
|
152
|
+
- **`AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch.** It drops every mutation
|
|
153
|
+
whatever it declared, and reports each one in the boot log like any other refusal.
|
|
154
|
+
- OAuth resource metadata is published at `/.well-known/oauth-protected-resource` (and at that path plus the mount
|
|
155
|
+
path, the spelling most clients try first). `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, and `AKAN_MCP_RESOURCE`
|
|
156
|
+
configure it; `insufficient_scope` is enforced only once `AKAN_MCP_SCOPES` is set. A token carrying no `aud` at
|
|
157
|
+
all is refused once `AKAN_MCP_AUTH_SERVERS` names an issuer — that issuer mints tokens for its other resources
|
|
158
|
+
too — and accepted while none is named, because a first-party Akan token is bound by app and environment.
|
|
159
|
+
- **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
|
|
160
|
+
description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
|
|
161
|
+
the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
|
|
162
|
+
their generated `Get X`. Write that model `.desc()` — it is the only text those entries can carry. There is no
|
|
163
|
+
`akan quality scan` rule for this any more: a source scanner found the exposure only as an `mcp:` literal, and
|
|
164
|
+
with exposure derived from the guards the resolved catalogue is the only place that can answer.
|
|
165
|
+
- A browser-hosted client needs `allowedOrigins` **and** the CORS answer the server sends back for those origins.
|
|
166
|
+
Every other MCP client sends no `Origin` at all, and the one that does is matched against the forwarded host so
|
|
167
|
+
a proxy does not turn each call into a 403 — which is only as trustworthy as an edge that *overwrites* that
|
|
168
|
+
header. `AKAN_MCP_RESOURCE` pins the resource identifier where you cannot guarantee it.
|
|
169
|
+
- **A `resources/read` uri that does not decode** — a stray `%` — is `Unknown resource`, not a server failure.
|
|
170
|
+
- **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
|
|
171
|
+
unparseable or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
|
|
172
|
+
published schema and nothing on the wire enforces it — and so does a document that is not there, as
|
|
173
|
+
`No <model> found for the arguments given.` A `prompt`, having no `isError` to carry a refusal, answers `-32602`.
|
|
174
|
+
Only a real failure logs a stack; an agent can drive the rest at will.
|
|
175
|
+
- **Three revisions are spoken**: the modern `2026-07-28` and the legacy `2025-11-25` / `2025-06-18`, which are
|
|
176
|
+
wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
|
|
177
|
+
disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
|
|
178
|
+
method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
|
|
179
|
+
gone".
|
|
180
|
+
- **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
|
|
181
|
+
body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
|
|
182
|
+
rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
|
|
183
|
+
are derived from the catalogue, so a server with no prompts does not advertise `prompts`.
|
|
184
|
+
- **An expired or wrongly-audienced bearer token is refused up front**, so an agent is told to authenticate rather
|
|
185
|
+
than that the tool does not exist. Its **signature is not checked** — that needs your app's own secret — so a
|
|
186
|
+
token signed wrong, like an opaque one, still degrades to an anonymous caller.
|
|
187
|
+
- **Resource URIs**: `akan://<model>/{id}`, `akan://<model>/list` for the model's own list, and
|
|
188
|
+
`akan://<model>/list/<sliceKey>` for a slice's. The root list takes no third segment on purpose — any token
|
|
189
|
+
there is one a slice could also be named. **Those three are the whole set**, so only the generated reads that
|
|
190
|
+
publish are addressable: a custom endpoint keeps its tool and gets no resource template, and the refused
|
|
191
|
+
`light<Model>` read has no address either.
|
|
192
|
+
- **The catalogue is one language**, `en` unless `language` says otherwise: it is built once at boot and cached by
|
|
193
|
+
clients, so there is no `Accept-Language` negotiation.
|
|
194
|
+
|
|
195
|
+
## `prompt()`
|
|
196
|
+
**`prompt()`** is invoked by the *user* — a client renders it as a slash command — not chosen by the model. `exec`
|
|
197
|
+
returns `PromptMessage[]`, or a bare string that is wrapped into one user message; build them with `Msg.user` /
|
|
198
|
+
`Msg.assistant` / `Msg.link` / `Msg.resource` / `Msg.image` / `Msg.imageOf`. It takes `.param()` and `.search()`
|
|
199
|
+
only, because `prompts/get` sends a flat string map. **An embedded payload is masked by the model you name** —
|
|
200
|
+
`Msg.resource(uri, task, { model: cnst.LightTask })`, or `Msg.mask(cnst.LightTask, task)` for one piece of an
|
|
201
|
+
assembly. Taking the model as an argument is what makes a `{ ...doc }` spread maskable, since that and `toJSON()`
|
|
202
|
+
arrive with the class already gone; a value with no model named whose `hidden`/`secret` fields are populated is
|
|
203
|
+
**refused**, one level into a plain object too. **A `prompt` is also mounted as a
|
|
204
|
+
plain HTTP `GET` whether or not you enabled MCP**, because that route is what lets a web UI preview one — and it
|
|
205
|
+
is in your OpenAPI document like any other `GET`, answering the one fixed `PromptMessage[]` shape. MCP exposure
|
|
206
|
+
gates the catalogue, not the surface, so guard it
|
|
207
|
+
like any other read — and a prompt declaring no
|
|
208
|
+
`guards` at all is named in the boot log, while an explicit `[Public]` is a decision and stays quiet. Every `Msg` builder takes
|
|
209
|
+
optional `annotations` last (`audience`, `priority` 0..1, `lastModified`) — give the instruction a high `priority`
|
|
210
|
+
and its attachments a low one, or a client with a full window drops blocks by position and keeps the attachment
|
|
211
|
+
over the ask.
|
|
212
|
+
|
|
213
|
+
## Progress Reporting
|
|
214
|
+
**`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
|
|
215
|
+
frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
|
|
216
|
+
websocket, and in tests. `McpProgress.streaming` says whether anyone is reading, for a report whose message
|
|
217
|
+
costs something to assemble.
|
|
@@ -21,6 +21,18 @@ Generate client state coordination for a module. Store connects generated fetch
|
|
|
21
21
|
- Do not perform persistence logic in React components when a store action can own it.
|
|
22
22
|
- Do not call private or internal signal operations from UI state.
|
|
23
23
|
|
|
24
|
+
## Form Draft Recovery
|
|
25
|
+
- `<model>FormDraft`, `restore<Model>FormDraft`, `discard<Model>FormDraft` and `load<Model>FormDraft` are
|
|
26
|
+
generated. Do not hand-write a draft action, and do not write `<model>Form` to browser storage yourself.
|
|
27
|
+
- The draft key carries the signed-in user, derived by hashing the auth token's payload with `iat`, `exp`, `nbf`
|
|
28
|
+
and `jti` removed. **Do not put a claim that changes on every issue into the JWT.** One would rotate the key on
|
|
29
|
+
every silent token refresh, and the symptom is a form that never recovers anything: the draft was written under
|
|
30
|
+
a key nothing looks for again, and it lingers until the seven-day sweep.
|
|
31
|
+
- `field.secret` and `field.hidden` values are never written to a draft, so they come back empty. That is the
|
|
32
|
+
intended trade — a user retypes a password — and is not something to work around.
|
|
33
|
+
- A model whose form holds a large `field.visual` body can pass the 256KB per-draft cap, and a draft over it is
|
|
34
|
+
dropped with a warning rather than truncated.
|
|
35
|
+
|
|
24
36
|
## Review Checklist
|
|
25
37
|
- The instruction points to current docs pages, not removed docs routes.
|
|
26
38
|
- Generated examples use current Akan builder APIs and scanner-friendly filenames.
|
|
@@ -17,6 +17,11 @@ Generate form and interaction fragments bound to store form state and generated
|
|
|
17
17
|
- Do not call service or signal directly from inputs.
|
|
18
18
|
- Do not duplicate validation that belongs in constants or service invariants.
|
|
19
19
|
- Do not build page layout here when Zone should compose it.
|
|
20
|
+
- Do not persist typed values yourself. The edit shells (`Load.Edit`, `Model.EditModal`, `Model.New`) save the
|
|
21
|
+
whole `<model>Form` and recover it on the next open; a Template that reaches for `localStorage` or a per-field
|
|
22
|
+
cache saves a fragment of a form and restores it over whatever the server just returned.
|
|
23
|
+
- The `Field.*` `cache` prop and the `Input` `cacheKey` prop are deprecated and store nothing. Turn recovery off
|
|
24
|
+
with `draft={false}` on the shell, or name a scope with `draft="<scope>"`; do not reintroduce a field-level one.
|
|
20
25
|
|
|
21
26
|
## Review Checklist
|
|
22
27
|
- The instruction points to current docs pages, not removed docs routes.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Query Rule",
|
|
3
|
+
"description": "Akan data access — filters, slices and hydration, full-text search, and cascade removal",
|
|
4
|
+
"group": "core",
|
|
5
|
+
"codegenPriority": 4,
|
|
6
|
+
"deprecated": false,
|
|
7
|
+
"scans": [
|
|
8
|
+
{
|
|
9
|
+
"type": "example",
|
|
10
|
+
"description": "Filter and model declarations that drive the generated query surface",
|
|
11
|
+
"path": "{apps,libs}/*/lib/*/*.document.ts",
|
|
12
|
+
"sample": 4
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"type": "example",
|
|
16
|
+
"description": "Slice declarations that expose a query to a client store",
|
|
17
|
+
"path": "{apps,libs}/*/lib/*/*.signal.ts",
|
|
18
|
+
"sample": 4
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"update": {
|
|
22
|
+
"filePath": "./queryRule.instruction.md",
|
|
23
|
+
"contents": ["slices and hydration", "generated filter methods", "text search", "cascade removal"],
|
|
24
|
+
"rules": [
|
|
25
|
+
"Use current Akan APIs only",
|
|
26
|
+
"State the rule and the reason it exists, never restate a type signature",
|
|
27
|
+
"Say which generated methods fire hooks and which do not",
|
|
28
|
+
"Keep it workspace-neutral — no app or repo names"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Query Rule — Filters, Slices, Hydration, Search, Cascade
|
|
2
|
+
|
|
3
|
+
How a model's data is queried, exposed to a client store, searched, and removed. Filters and slices are the two
|
|
4
|
+
query surfaces; `q.search()` and the `text` field roles are the search surface; `cascade` is what a removal takes
|
|
5
|
+
with it. `conventions` carries the invariants — this is the full contract behind them.
|
|
6
|
+
|
|
7
|
+
## Slices, Queries, and Hydration
|
|
8
|
+
|
|
9
|
+
- **The root slice (`""`, generated by `slice()`) takes `queryKey` and `args`, not a query.** `initX("byOwner",
|
|
10
|
+
[ownerId])` names one of the model's own filters and passes that filter's args; no key at all is the `any`
|
|
11
|
+
filter every model carries. A raw query descriptor let a caller compose a query the model never declared, and
|
|
12
|
+
could not survive the query string at all — `String(value)` sends `[object Object]`. Args are parsed by the type
|
|
13
|
+
the filter declared, a missing required one is refused, and args past the declared ones are dropped
|
|
14
|
+
(`resolveFilterQuery`, `pkgs/akanjs/document/filterMeta.ts`). It is an admin API: `root:` is always `Admin`.
|
|
15
|
+
- **Give an id filter arg a `ref` and the admin panel stops asking for hex.** `filter().arg("ownerId", ID, { ref:
|
|
16
|
+
"user" })` names the model the id points at; the arg then renders a picker that runs the same query maker against
|
|
17
|
+
that model's own filters, so a ref of a ref keeps nesting. The picker holds its rows in local state rather than the
|
|
18
|
+
ref model's store — that store is a singleton, and loading into it would overwrite whatever listing of the same
|
|
19
|
+
model is already on screen. `ref` is the only UI hint a filter arg carries; there is no render callback, because a
|
|
20
|
+
function cannot cross to the client.
|
|
21
|
+
- **A summary counter names the listing it counts, on the field itself.** `field(Int).meta(getQueryMeta("user")
|
|
22
|
+
.query("byStatuses").args([["prepare"]]))` is what makes the dashboard tile clickable: clicking it applies that
|
|
23
|
+
filter to the listing in place — one request, no navigation — and fills the arg controls under the toolbar, so
|
|
24
|
+
the filter is visible and editable rather than opaque. `args` may be a thunk (`() => [dayjs().subtract(1,
|
|
25
|
+
"hour")]`), read at the click so a relative time is current. A meta naming a different model counts a different
|
|
26
|
+
listing, so its tile stays inert here. `AdminPanel`'s `queryMap` still overrides per column, and is now only for
|
|
27
|
+
a column whose field cannot declare one.
|
|
28
|
+
- **A picker row is labelled by `labelOf`, which reads `Light<Model>.label()` first.** Then the field carrying
|
|
29
|
+
`text: "title"`, then `title` / `name`, with the id as the floor. Write the method on the Light class — that is
|
|
30
|
+
where display logic belongs, and the same label is what `Load.Units` and the in-page agent's screen scope show.
|
|
31
|
+
A model with none renders ids and says so in the picker.
|
|
32
|
+
- A slice's `exec` returns a `QueryOf` (an opaque query descriptor, `pkgs/akanjs/constant/types.ts`); you **cannot** chain `.sort()`/`.limit()` on it.
|
|
33
|
+
- Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })` (`pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts`).
|
|
34
|
+
- **A slice list is either one window or an accumulated one, and the store says which.** `setPageOf<Model>` swaps
|
|
35
|
+
the window and `pageOf<Model>` names it; `loadMoreOf<Model>()` appends the rows *after* the ones already loaded
|
|
36
|
+
and takes no page number, because in an accumulated list no page number names anything. It skips by
|
|
37
|
+
`<model>List.length` rather than by `page * limit`, which is what keeps the offset from drifting from what is on
|
|
38
|
+
screen: a live insertion at the front moves the server's baseline by one, so a page number asks for rows the
|
|
39
|
+
list already shows. It also leaves `pageOf<Model>` at 1 — live placement is refused anywhere but the first page,
|
|
40
|
+
so paging by number used to switch live sync off permanently on the first "more", silently. `isCumulativeOf<Model>`
|
|
41
|
+
is the mode, and it is what stops a live insertion from truncating an accumulated list back to one page's worth.
|
|
42
|
+
- **"Is there more" is `hasMoreOf<Model>`, never `count > page * limit`.** It is set from the length of the batch
|
|
43
|
+
the server just returned, so it survives `{ insight: false }` — which makes no count query at all — and cannot
|
|
44
|
+
drift from the count a live event moved. `loadMoreOf<Model>()` is a no-op once it is false, and `Load.Units`
|
|
45
|
+
draws no infinite-scroll sentinel.
|
|
46
|
+
- Generated list accessors like `listBy(...)` return `Promise<Doc[]>`. For a chainable builder (`.sort().skip().limit().select()`) use the model facade's `findMany`/`findOne` (`FindManyChain`, `pkgs/akanjs/document/into.ts`).
|
|
47
|
+
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`); client fetch results are raw `GetStateObject` plain data (functions stripped, `pkgs/akanjs/base/types.ts`).
|
|
48
|
+
- Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
|
|
49
|
+
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four are
|
|
50
|
+
query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use them on a
|
|
51
|
+
model that carries no removal side effect; otherwise remove documents one at a time.
|
|
52
|
+
- **`update<Filter>` / `updateOne<Filter>` are chains, not calls:** `await updateInRoot(rootId).set({ status:
|
|
53
|
+
"archived" })`. The patch cannot trail the filter args — a filter's own args may be optional and no tuple type
|
|
54
|
+
puts a required element after those — so it lands on a terminal `.set()`, mirroring the `UPDATE … SET …` it
|
|
55
|
+
compiles to. Building the chain touches nothing; only `.set()` runs a query.
|
|
56
|
+
- `removeOne` / `updateOne` hit the **newest** match — the subquery they compile to is ordered `createdAt` descending
|
|
57
|
+
and there is no way to change that. They also report only counts, never which row they touched, so they are for
|
|
58
|
+
"there is at most one of these", not for claiming the next item off a queue. Pass a query that matches one row.
|
|
59
|
+
- **A filter may not be keyed after its own model.** Filter methods are assigned after CRUD, so a filter `chat` on
|
|
60
|
+
model `chat` would silently swap the single-document `removeChat`/`updateChat` for a hookless query-level one. It
|
|
61
|
+
throws at boot instead (`assertFilterFitsCrud`).
|
|
62
|
+
- **"Has no value" is `q.empty`, never `q.missing`.** The three absence operators are distinct: `q.exists` is the
|
|
63
|
+
key being present, `q.missing` is the key being *absent from the stored JSON*, and `q.empty` is either — absent,
|
|
64
|
+
or present and null. An optional field is written both ways over its life: an insert that omits it leaves the key
|
|
65
|
+
out, while a document read back and saved carries the explicit `null` that the read materialized. So `q.missing`
|
|
66
|
+
matches a freshly inserted row and silently stops matching that same row after its first round-trip save. Reach
|
|
67
|
+
for it only to find rows written before the field was declared.
|
|
68
|
+
|
|
69
|
+
## Text Search In A Filter — `q.search()`
|
|
70
|
+
|
|
71
|
+
- Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
|
|
72
|
+
q.search(text, { prefix: true }))`. The generated `listBySearch` / `countBySearch` / `queryBySearch` /
|
|
73
|
+
`insightBySearch` come for free — you do **not** need a slice to make search usable.
|
|
74
|
+
- **Only add a search slice when the model's data is safe to enumerate.** A filter is server-side; a slice is a
|
|
75
|
+
client-callable endpoint, so on a model whose slice `get:` is `Public` a search slice hands anyone a way to walk the
|
|
76
|
+
table. Leave that decision to the mounting app.
|
|
77
|
+
- `q.search()` compiles to a JOIN, not a WHERE fragment, so it **must sit at an AND position**. Nesting it under
|
|
78
|
+
`q.any()` or `q.not()` throws, and it is rejected in `updateOneByQuery` / `updateManyByQuery` — a query-level write
|
|
79
|
+
takes no join, so ignoring the node would silently widen the write to every other matching row.
|
|
80
|
+
- Blank or whitespace-only input matches **nothing**. Never "fix" that into a passthrough: a passthrough turns a
|
|
81
|
+
search endpoint into a full listing.
|
|
82
|
+
- Order by relevance with the built-in `relevance` sort key. It is an empty sort map, which the store reads as
|
|
83
|
+
"unspecified": score order when a search join is present, `createdAt` descending otherwise. That fallback is the
|
|
84
|
+
compiler's own, not a model-defined default — redefining `latest` on the model does not change it.
|
|
85
|
+
- **A slice endpoint never reaches "unspecified".** The resolver fills `latest` before the query is built, so a
|
|
86
|
+
client asking for the score order has to name `relevance`; leaving `sort` off gets `latest`, not relevance.
|
|
87
|
+
- Scope a search with `columns` (`q.search(text, { columns: ["title"] })`) and re-weight with `weights`, a tuple of
|
|
88
|
+
finite numbers positional over `["title", "desc", "tag", "filter"]`.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
## Text Search Fields — the `text` role
|
|
92
|
+
|
|
93
|
+
- A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and likewise
|
|
94
|
+
`"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model switch.
|
|
95
|
+
- Pick the role by what the value *is*, because `bm25` weights them positionally (`title` 10, `tag` 3, `desc` 1,
|
|
96
|
+
`filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword list, `filter` is a
|
|
97
|
+
scoping value (status, owner, role) that must be matchable but must never outrank a real title hit.
|
|
98
|
+
- `thumb` is mirrored for rendering a hit and is **not** indexed — never expect it to match.
|
|
99
|
+
- **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
|
|
100
|
+
mirror is plaintext and an indexed secret would leak through search. The class build throws the same refusal as
|
|
101
|
+
a backstop, for an option object the excess-property check cannot see through, and names the way out: drop the
|
|
102
|
+
role, or leave the field unmasked. Do not work around either. The throw also covers a `text` field *underneath*
|
|
103
|
+
one of those — a scalar's own field is reachable through its parent, so `f.secret(Noti)` where `Noti.label`
|
|
104
|
+
carries a role is rejected at the parent, not silently indexed; that one is only reachable at runtime, since the
|
|
105
|
+
pairing spans two files.
|
|
106
|
+
- The role works on a relation too (`image: field(File, { text: "thumb" })`) and on an array (`playing: field([String],
|
|
107
|
+
{ text: "tag" })`); an array of objects indexes by leaf key, including an array leaf (`works[*].tags`). A field
|
|
108
|
+
inside a `Map` indexes nothing: there is no fixed path to extract it from.
|
|
109
|
+
- Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
|
|
110
|
+
because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
|
|
111
|
+
path.
|
|
112
|
+
- Search runs on sqlite/libsql only. `q.search()` against Postgres throws, loudly, rather than returning every row.
|
|
113
|
+
- `AKAN_SEARCH_ENABLED=0` switches the index off process-wide; unset means on. It never deletes mirror data, and
|
|
114
|
+
re-enabling reconciles every ref. **Give every process the same value** — a process cannot drop triggers for models
|
|
115
|
+
it does not mount, so a mixed fleet leaves stale triggers behind.
|
|
116
|
+
- The tokenizer is `AKAN_SEARCH_TOKENIZER` (or `database.search.tokenizer`, which wins), defaulting to
|
|
117
|
+
`unicode61 remove_diacritics 2`. Changing it rebuilds the index from the mirror on the next boot — the model
|
|
118
|
+
tables are never re-read — so it is a safe knob, unlike a `text` role change, which re-reads every row. The
|
|
119
|
+
rebuild takes no cross-process claim, so a fleet restarted at once repeats it in every process; stagger the
|
|
120
|
+
restart when the mirror is large.
|
|
121
|
+
|
|
122
|
+
## Cascade Remove — the `cascade` option
|
|
123
|
+
|
|
124
|
+
**The value names the direction, and getting it wrong is a data loss.** The two actions can sit on the same field
|
|
125
|
+
shape, so `cascade` never means "related" — it means one of exactly these:
|
|
126
|
+
|
|
127
|
+
- `removeRef` — *when I am removed, remove what this field points at.* Declared on the relation the owner holds:
|
|
128
|
+
`image: field(File, { cascade: "removeRef" })`, arrays included. Only a relation accepts it; a primitive, a bare
|
|
129
|
+
`ID`, and a scalar each fail the class build.
|
|
130
|
+
- `removeWith` — *when what this field points at is removed, remove me.* Declared on the child's own reference to
|
|
131
|
+
its owner, so the owner never learns about its children and a lib model can be extended by an app's. Three forms:
|
|
132
|
+
a relation (`field(AgentSession, { cascade: "removeWith" })`), an id with `ref`, or a polymorphic id with
|
|
133
|
+
`refPath`. An array, a Map, `ref` together with `refPath`, and a field naming no owner each fail the class build.
|
|
134
|
+
- **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time, so every
|
|
135
|
+
model's removal would have to sweep the polymorphic table on the chance it is the owner.
|
|
136
|
+
- **`polymorphic: "any"` buys that sweep on purpose**, for the one shape an enum cannot express: a child whose
|
|
137
|
+
owner is any model in the app, named by a free-form `String` holding the owner's refName.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
parent: field(ID, { refPath: "parentType", cascade: "removeWith", polymorphic: "any" }),
|
|
141
|
+
parentType: field(String), // holds the owner's refName
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The lookup itself is cheap — the declaration auto-creates the same `{ removedAt, typeKey, fk }` index, so a
|
|
145
|
+
removal that owns nothing is one index probe returning zero rows, not a scan. **The price is bulk: one wildcard
|
|
146
|
+
edge anywhere turns every cascade in the app back to one document at a time**, because a query-level removal of
|
|
147
|
+
*any* model would be a removal whose wildcard children were never looked for. The boot log says so in one
|
|
148
|
+
`info` line naming the edges — that line is the answer to "why does this app remove everything per document".
|
|
149
|
+
The opt-in is what keeps an app that declared none on the fast path.
|
|
150
|
+
|
|
151
|
+
Refused at the class build: `polymorphic: "any"` on a `refPath` that already names an `enumOf` (the enum names
|
|
152
|
+
the candidates and indexes better — keep one), a `typeKey` that is not a `String` (the sweep matches a refName
|
|
153
|
+
against that column, and a column that cannot hold one silently finds nothing), and the option anywhere other
|
|
154
|
+
than a `cascade: "removeWith"` field with a `refPath`. A wildcard owner is also exempt from the mount check
|
|
155
|
+
that a monomorphic owner fails at boot — it names no module to mount.
|
|
156
|
+
- **A cascade goes through the target's service, never its model** — that path is what runs the target's
|
|
157
|
+
`_postRemove`, where a module puts the side effect the removal has to carry.
|
|
158
|
+
- **Bulk is decided at boot, per target model, for both directions.** A target with no `remove` schema hook, no
|
|
159
|
+
`_pre`/`_postRemove`, no cascade of its own, and no children takes one `removeManyByQuery`. Adding a
|
|
160
|
+
`_postRemove` silently flips it back to one document at a time — the boot log is the only place that shows.
|
|
161
|
+
- **The plan is sealed after every service is live**, so a `listenPost("remove")` registered in `onInit` counts,
|
|
162
|
+
and an unmounted `removeRef` target or `removeWith` owner fails the boot rather than the first removal. An
|
|
163
|
+
unmounted `refPath` candidate only warns, since that list spans optional modules by design.
|
|
164
|
+
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped
|
|
165
|
+
by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
|
|
166
|
+
- Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
|
|
167
|
+
restorable, and reviving the owner does not revive what went with it.
|
|
168
|
+
- A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
|
|
169
|
+
polymorphic); every non-base field lives in the `_doc` JSON column, so the lookup would otherwise scan the table.
|
|
170
|
+
- **Query-level removes fire no hooks and therefore no cascade** — `removeManyByQuery` / `updateManyByQuery`, the
|
|
171
|
+
generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
|
|
172
|
+
at a time when it cascades.
|
|
173
|
+
- Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write. Cycles are cut by a
|
|
174
|
+
visited set carried down the whole chain, with a depth cap of 16.
|
|
175
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Runtime Rule",
|
|
3
|
+
"description": "Akan build and runtime surfaces — web/csr toggles, the gateway/solo process model, logging, the generated image, and shipped assets",
|
|
4
|
+
"group": "core",
|
|
5
|
+
"codegenPriority": 4,
|
|
6
|
+
"deprecated": false,
|
|
7
|
+
"scans": [
|
|
8
|
+
{
|
|
9
|
+
"type": "example",
|
|
10
|
+
"description": "App and lib runtime declarations",
|
|
11
|
+
"path": "{apps,libs}/*/akan.config.ts",
|
|
12
|
+
"sample": 4
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"update": {
|
|
16
|
+
"filePath": "./runtimeRule.instruction.md",
|
|
17
|
+
"contents": ["web surface toggles", "process model", "logging and live tail", "generated image", "shipped assets"],
|
|
18
|
+
"rules": [
|
|
19
|
+
"Use current Akan APIs only",
|
|
20
|
+
"State the rule and the reason it exists, never restate a type signature",
|
|
21
|
+
"Name the env that narrows a build-time declaration wherever one exists",
|
|
22
|
+
"Keep it workspace-neutral — no app or repo names"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|