@akanjs/cli 3.1.0-rc.1 → 3.1.0-rc.11
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-kyprmy06.js → CodeAgent-rkjyxgec.js} +2 -2
- package/{CodeTui-21mtze3j.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-5s780vnz.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-yj6xb2a7.js → cloud.command-9cp1t0f5.js} +60 -67
- package/cloud.script-dgstqt1m.js +24 -0
- package/{code.command-3wkn4ja5.js → code.command-0ad6gw2a.js} +6 -6
- package/{context.command-9jjn3gcw.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 -1071
- 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 +43 -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-rtct7d43.js → index-20bxxyvn.js} +80 -80
- package/{index-qt22xstj.js → index-35hc95h5.js} +5 -3
- package/{index-8r4d7cmt.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-krq7btbt.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-rc5rwyps.js → index-bspm21y1.js} +71 -60
- package/index-cpb3nvma.js +667 -0
- package/{index-jx4bzvwg.js → index-d47ypebv.js} +97 -55
- package/{index-ae5pwhgs.js → index-d72hfmdm.js} +15 -13
- package/{index-e0cvze1s.js → index-fmzcs8v6.js} +4 -4
- package/{index-8ggztp8k.js → index-fyq1z6kt.js} +68 -201
- package/{index-1w2qaee2.js → index-g1mbppa9.js} +331 -259
- 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-56d2g8ms.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-vsreyy42.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-md2teygq.js → subspace.command-5c7yasd7.js} +424 -364
- 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-jx3tdm2d.js → tunnel.command-2kw8vt9g.js} +8 -4
- package/{workflow.command-jx0wag5r.js → workflow.command-2djwmpqf.js} +10 -10
- package/{workspace.command-yxd7rr7g.js → workspace.command-6qfgq4b4.js} +38 -34
- package/TunnelShare-q4jq08qg.js +0 -9
- package/cloud.script-h6pvcpa2.js +0 -20
- package/context.runner-atcrzz5b.js +0 -21
- package/index-6w0rbr9d.js +0 -68
- package/index-n68jx79w.js +0 -120
- package/index-pgj2rt78.js +0 -404
|
@@ -11,13 +11,28 @@ Use TailwindCSS with Akan's semantic design-token layer and the `akanjs/ui` prim
|
|
|
11
11
|
- Compose classes with `cn` from `akanjs/client` (token-aware tailwind-merge) — the only class-combining function. No object syntax: write `cond && "x"`.
|
|
12
12
|
- Forward `className` last so callers can extend styles.
|
|
13
13
|
|
|
14
|
+
## Color Vocabulary — What Lint Refuses
|
|
15
|
+
- Vocabulary closure strips the raw Tailwind palette, so these render as no CSS and fail lint: raw palette classes
|
|
16
|
+
(`bg-blue-500`, `text-gray-400`), arbitrary color values (`bg-[#3b82f6]`), an arbitrary class built by
|
|
17
|
+
interpolation, daisyUI legacy classes (`btn-primary`, `card-body`) and its dropped color slots (`bg-base-100`,
|
|
18
|
+
`text-base-content`, `text-primary-content`, `bg-error` — use `background`/`muted`/`border`, `foreground`,
|
|
19
|
+
`<color>-foreground`, `destructive`), and color literals in `style={{...}}` (`no-raw-palette-class`,
|
|
20
|
+
`no-arbitrary-color`, `no-daisyui-legacy-class`, `no-inline-color`, `no-interpolated-arbitrary-class`).
|
|
21
|
+
- A legitimate fixed color (OS-chrome mockups, data-viz) takes a `// biome-ignore lint/plugin: <reason>`.
|
|
22
|
+
- Color literals in SVG color attributes (`fill`, `stroke`, `stopColor`, `floodColor`, `lightingColor`, `color`) and
|
|
23
|
+
in `el.style.<prop> = …` / `el.style.setProperty(…)` writes are flagged as warnings for now; `currentColor`,
|
|
24
|
+
`none`, `transparent`, `var(--…)` and `url(#id)` stay legal.
|
|
25
|
+
- Class order is the sorter's: `nursery/useSortedClasses` is an error and also sorts the string arguments to `cn()`.
|
|
26
|
+
Output such as `font-bold text-2xl text-foreground` or `border-foreground/5 border-t` is correct — write the
|
|
27
|
+
classes in any order, run the formatter, leave the result.
|
|
28
|
+
|
|
14
29
|
## Recipe Layer
|
|
15
30
|
- Reusable or variant-like styling belongs in a recipe (a cva-style factory), not repeated inline class stacks.
|
|
16
31
|
- Framework primitives ship recipes: `buttonRecipe` and `badgeRecipe` from `akanjs/ui`. Call them as `buttonRecipe(variants, className?)` — the second argument is merged automatically, so you never wrap it in `cn()`:
|
|
17
32
|
- `buttonRecipe({ variant: "primary", size: "lg" }, "w-full rounded-2xl")`
|
|
18
33
|
- Prefer the `akanjs/ui` primitives (`Button`, `Badge`, `Input`, `Field`, `Table` …) over re-implementing a component's look with utility stacks. There is **no `Card`/`Box` primitive** — a card/box is a recipe surface (e.g. `appCard`), not an `akanjs/ui` component; do not `import { Card }`.
|
|
19
34
|
- App-specific repeated surfaces go one-per-file in `apps/<app>/ui/Recipe/` (server-safe, no `"use client"`) via `recipe(tv({ base, variants }))`, imported from `@apps/<app>/ui`. App recipes **extend** (surfaces the lib lacks — chat bubble, tile); they never re-define a lib component in parallel.
|
|
20
|
-
- Full recipe authoring/consumption policy: the `recipeRule` guideline. Never guess recipe names/imports — the authoritative list is `AGENTS.md` `##
|
|
35
|
+
- Full recipe authoring/consumption policy: the `recipeRule` guideline. Never guess recipe names/imports — the authoritative list is `AGENTS.md` `## Recipes` plus the `list_recipes` tool.
|
|
21
36
|
|
|
22
37
|
## Customization Decision (where a design delta goes)
|
|
23
38
|
Screen code is invariant — a plain `<Button variant="primary">` never changes. Only these config files do:
|
|
@@ -64,19 +79,25 @@ Screen code is invariant — a plain `<Button variant="primary">` never changes.
|
|
|
64
79
|
|
|
65
80
|
## Frame Variables (the page's chrome and the device's insets)
|
|
66
81
|
|
|
67
|
-
The frame writes these on `#frameRoot` and `<html>`, so any class can read them
|
|
82
|
+
The frame writes these on `#frameRoot` and `<html>`, so any class can read them. The insets and the content height
|
|
83
|
+
are there on every surface; the three keyboard variables are written only on the server-rendered web:
|
|
68
84
|
|
|
69
85
|
- `--akan-top-safe-area` / `--akan-bottom-safe-area` — the notch and the home indicator. On the web they are
|
|
70
86
|
`env(safe-area-inset-*)` where the route chain declares `safeArea` (`.config({ safeArea: true })` on the root
|
|
71
|
-
layout) and `0px` where it does not
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
layout) and `0px` where it does not, and `env()` measures nothing unless the viewport meta carries
|
|
88
|
+
`viewport-fit=cover` — the root layout's `.viewport({ fitCover: true })`, which a native app has by default; in a
|
|
89
|
+
native app they are the shell's inset. Use the variable alone: never write `env(safe-area-inset-*)` beside it, and
|
|
90
|
+
never subtract one from the other. The frame paints the bottom strip only under bottom chrome (a `BottomInset` or
|
|
91
|
+
`BottomTab`) or a route's own `bottomSafeAreaColor`; elsewhere a page's `fixed bottom-0` bar paints its own
|
|
92
|
+
safe-area padding.
|
|
75
93
|
- `--akan-top-inset` / `--akan-bottom-inset` — the height of the page's top bar and bottom tab slot, per breakpoint
|
|
76
94
|
when the page declares a responsive inset. A slot whose inset is 0 at a width clips its chrome there and hides it
|
|
77
95
|
from the keyboard and screen readers, so a phone-only bar needs no `md:hidden`.
|
|
78
96
|
- `--akan-keyboard-inset`, `--akan-viewport-height`, `--akan-viewport-offset-top` — the on-screen keyboard over the
|
|
79
|
-
page's bottom, the visible height, and how far iOS panned the page to show a field (the top slots follow it).
|
|
97
|
+
page's bottom, the visible height, and how far iOS panned the page to show a field (the top slots follow it). Only
|
|
98
|
+
an SSR page in a browser writes them; a CSR bundle and a native app never do, so read them with a fallback
|
|
99
|
+
(`var(--akan-keyboard-inset, 0px)`), or take the keyboard from the store's `keyboardHeight`, which a native app's
|
|
100
|
+
keyboard plugin fills.
|
|
80
101
|
- `--akan-content-height` — the room between the insets. iOS pans under the keyboard instead of shrinking the page,
|
|
81
102
|
so a column sized by it leaves the panned-to area blank: keep a keyboard-heavy screen's input bar in flow at the end
|
|
82
103
|
of its column, or in the keyboard slot.
|
|
@@ -10,8 +10,70 @@ Use this whenever generating constants or scalar constants. It defines the curre
|
|
|
10
10
|
- `field.hidden(Type).optional()` is for internal state that should not be treated as a normal public field.
|
|
11
11
|
- `field.secret(Type).optional()` is for sensitive values that should not be selected by default.
|
|
12
12
|
- Neither survives an endpoint response: both are stripped on the way out and hydration writes `null` over the key, while the generated type still declares the field — so a client holds a deliberate `null` behind a type that promises a value, and only `??` / `== null` catch it. A field a browser has to read is neither.
|
|
13
|
+
- `field.visual(Type)` is a field the page renders and an agent never sees (below).
|
|
13
14
|
- `field([Type])` defines arrays; provide empty array defaults when the app expects list operations.
|
|
14
15
|
|
|
16
|
+
## Field Types
|
|
17
|
+
- **Import from `akanjs/base`** (real classes/helpers, not globals): `Int`, `Float`, `ID`, `Any`, `Binary`, `Upload`,
|
|
18
|
+
`ImageResult`, `enumOf`, and the `dayjs` factory. There is **no `JSON` scalar** — use `Any` for open/flexible
|
|
19
|
+
payloads, and only when the content is genuinely flexible.
|
|
20
|
+
- **Use the JS globals directly (no import needed)**: `String`, `Boolean`, `Date`. They are monkey-patched to
|
|
21
|
+
behave like scalars, so `field(String)` typechecks.
|
|
22
|
+
- **`Number` is not a valid field/body type.** `NumberConstructor` is intentionally not augmented, so
|
|
23
|
+
`field(Number)` / `.body("x", Number)` fails to typecheck. Use `Int` for whole-number counts and quantities and
|
|
24
|
+
`Float` only for values that need decimals.
|
|
25
|
+
- Runtime resolution of every scalar (globals included) goes through `PrimitiveRegistry` by `refName`
|
|
26
|
+
(`pkgs/akanjs/base/primitiveRegistry.ts`).
|
|
27
|
+
- **`Binary`, `Upload` and `ImageResult` are never model fields.** The class build refuses `field(Binary)` naming
|
|
28
|
+
the `File` model instead, because every non-base field lives in the `_doc` JSON column and bytes would sit there
|
|
29
|
+
as base64 and ride every read of the row. How each travels on the wire: `transportRule` and `mcpRule`.
|
|
30
|
+
- Defaults are a literal for scalars and a thunk for anything constructed (`default: () => dayjs()`, so the value
|
|
31
|
+
is created at runtime). Optional is the postfix `.optional()`.
|
|
32
|
+
|
|
33
|
+
## Masked And Visual Fields
|
|
34
|
+
- **A `field.hidden` / `field.secret` value reads `null` on the client, and the type still declares it.**
|
|
35
|
+
`SignalContext.resolveReturn` skips both field types while it builds an endpoint's response, and hydration
|
|
36
|
+
writes `null` over the key rather than leaving it absent — the first branch of the loop in
|
|
37
|
+
`constant/getDefault.ts`, ahead of the field's own default. Every use of it typechecks, and the failure lands
|
|
38
|
+
wherever it is finally dereferenced instead of where it was read. **Guard with `??` or `== null`**:
|
|
39
|
+
`=== undefined`, a destructuring default and an optional parameter default all catch only a missing key and sail
|
|
40
|
+
straight past this one. A projection (`pickById(id, { secret: true })`) widens the *server's* read, never the
|
|
41
|
+
response. If a screen needs the value, the field is neither hidden nor secret; if it only needs to be cheap for a
|
|
42
|
+
model rather than unseen, that is `field.visual`.
|
|
43
|
+
- **`field.visual(T)` is a field the page renders and an agent never sees** — a blur placeholder, a rendered HTML
|
|
44
|
+
body, a serialized geometry. It stays an ordinary stored `property` (persistence, search, forms and the page
|
|
45
|
+
response untouched) and is stripped wherever a value is masked for an AI caller: every in-page-agent read and
|
|
46
|
+
every MCP result, along with the MCP readable schema. Unlike `hidden`/`secret` it is cost, not secrecy — nothing
|
|
47
|
+
is refused over one. Reach for it whenever a field is bulky and useless to a model; that is cheaper than every
|
|
48
|
+
tool learning to avoid it.
|
|
49
|
+
- `field.secret`, `field.hidden` and `resolve()` take no `text` search role (`queryRule`).
|
|
50
|
+
|
|
51
|
+
## Immutable Fields
|
|
52
|
+
- A document-path change to an `{ immutable: true }` field is refused with `base.error.immutableField` (400,
|
|
53
|
+
`data.field`), which the client toasts in the user's language; query-level writes are not checked.
|
|
54
|
+
|
|
55
|
+
## Date Fields On An Instance
|
|
56
|
+
- **A model instance's `Date` fields are prototype accessors, not own properties.** `user.createdAt` is still a
|
|
57
|
+
`Dayjs`, built on first read from a native `Date` the instance holds under a symbol. So `Object.keys(user)` and
|
|
58
|
+
`{ ...user }` do **not** include them, while `"createdAt" in user`, `for...in`, `JSON.stringify(user)`,
|
|
59
|
+
`plainFieldsOf(user)` (`akanjs/common`), `immerify`, and `deepObjectify` all do. To copy a model,
|
|
60
|
+
`new cnst.X().set(user)`, never a spread.
|
|
61
|
+
|
|
62
|
+
## Image And File Fields
|
|
63
|
+
- **Do not declare `Upload` as a model field.** `Upload` is a signal-body-only primitive. Declare an image/file
|
|
64
|
+
field as a relation to `File`: `image: field(File).optional()` for one, `images: field([File])` for many.
|
|
65
|
+
- The store then auto-generates an `upload<Field>On<Model>(fileList)` action that calls the framework upload
|
|
66
|
+
mutation and polls file status until it leaves `"uploading"` (`pkgs/akanjs/store/action.ts`).
|
|
67
|
+
- Storage is wired through the `StorageAdaptor` DI role (default `BlobStorage`,
|
|
68
|
+
`pkgs/akanjs/service/predefinedAdaptor/storage.adaptor.ts`); the reference implementation is the `file` module.
|
|
69
|
+
Do not hand-roll data-URL fallbacks. `{ cascade: "removeRef" }` on the field removes the file with its owner
|
|
70
|
+
(`queryRule`).
|
|
71
|
+
- For a file your own code wrote through `plug(StorageAdaptorRole)`, the disk path is
|
|
72
|
+
`storage.resolveLocalPath?.(path, access)` — `null` means refused (rooted, NUL, or outside the root; never fall
|
|
73
|
+
back to `readData()`), `undefined` means the adaptor keeps no local files, and an untrusted key passes
|
|
74
|
+
`access: "public"`. A `File` field is not one: `libs/shared` stores it through its own `StorageApi`, which may be
|
|
75
|
+
object storage. Never cast the role to read `BlobStorage`'s `root` / `privateRoot`.
|
|
76
|
+
|
|
15
77
|
## Current Akan Patterns
|
|
16
78
|
- Use `String`, `Boolean`, `Date`, `Int`, `Float`, `ID`, scalar classes, model light classes, or enum classes as field types.
|
|
17
79
|
- Use `enumOf()` classes for categorical values that need type-safe values and dictionary entries.
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
# Akan.js Framework Guide
|
|
2
2
|
|
|
3
3
|
## Ownership
|
|
4
|
-
- `
|
|
5
|
-
- `libs/<lib>` contains reusable domain and utility libraries consumed by apps.
|
|
6
|
-
- `pkgs/akanjs` contains framework facets such as base, constant, document, service, signal, store, client, ui, and CLI tooling.
|
|
4
|
+
- `pkgs/akanjs` facets include base, constant, document, service, signal, store, client, ui, and CLI tooling.
|
|
7
5
|
- Domain behavior lives near the model folder instead of being split by technical layer first.
|
|
8
6
|
|
|
9
7
|
## Current Akan Patterns
|
|
10
8
|
- Database module flow is `constant -> dictionary -> document -> service -> signal -> store -> UI`.
|
|
11
|
-
- Scalars
|
|
12
|
-
|
|
13
|
-
- Generated registry files such as `cnst.ts`, `db.ts`, `dict.ts`, `sig.ts`, `srv.ts`, and `st.ts` are scanner outputs and should not be hand-authored.
|
|
9
|
+
- Scalars (`lib/__scalar/<scalarName>`) represent embedded value objects; service modules (`lib/_<serviceName>`) hold
|
|
10
|
+
behavior not centered on one stored model.
|
|
14
11
|
- Pages and components should consume generated client/server helpers rather than duplicating model shapes.
|
|
15
12
|
|
|
16
13
|
## Codegen Rules
|
|
@@ -23,5 +20,5 @@
|
|
|
23
20
|
When a request implies a distinct look and feel, do not stop at colors — customize both the theme and, when needed, the components.
|
|
24
21
|
|
|
25
22
|
- **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`).**
|
|
23
|
+
- **Lib tokens (`libs/<lib>/ui/tokens.css`).** The one CSS file a lib owns: colors its own components pin that must **not** follow the theme — a vendor brand color, a fixed surface — declared once as plain `:root` custom properties and compiled automatically into every app whose pages reach the lib, ahead of the app's stylesheets, so nothing is imported by hand. Reference them as `bg-[var(--kakao)]`; never copy the block into each app. `@theme` extensions stay in the app stylesheet, because the color vocabulary is closed per stylesheet, and theme-following colors are the app's.
|
|
27
24
|
- **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.
|
|
@@ -34,7 +34,8 @@ export class FileSlice extends slice(srv.file, {
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
- **It is curation, not authorization.** HTTP serves the endpoint exactly as before and the guards are still the
|
|
37
|
-
only thing deciding who may call it. Never write `mcp: false` where you meant a guard.
|
|
37
|
+
only thing deciding who may call it. Never write `mcp: false` where you meant a guard. To take generated CRUD off
|
|
38
|
+
HTTP too, write `guards: { cru: false }`.
|
|
38
39
|
- **The `mcp` map on `slice()` mirrors the `guards` map above it** — same keys (`root`, `get`, `cru`, `create`,
|
|
39
40
|
`update`, `remove`), same fallbacks (`create`/`update`/`remove` inherit `cru`), and the same scope: it narrows
|
|
40
41
|
the root slice and the generated CRUD, and never reaches a named slice or a custom endpoint. Those declare
|
|
@@ -55,7 +56,8 @@ export class FileSlice extends slice(srv.file, {
|
|
|
55
56
|
## Configuration
|
|
56
57
|
Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
|
|
57
58
|
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`,
|
|
58
|
-
`promptBudget`, `apps`, `modern`, and `auth
|
|
59
|
+
`promptBudget`, `apps`, `modern`, and `auth` — or `setMcp((env) => …)` for settings derived from the server env.
|
|
60
|
+
**Not `main.ts`**: the gateway
|
|
59
61
|
there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
|
|
60
62
|
mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
|
|
61
63
|
declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
|
|
@@ -155,12 +157,23 @@ export default page()
|
|
|
155
157
|
clients read the structured half. A scalar return is unaffected: it has no structured half to point at, so it
|
|
156
158
|
ships as the value itself either way. `resources/read` is unaffected too: `ReadResourceResult` has no
|
|
157
159
|
`structuredContent`, so a resource's text is its only channel and always carries the serialized value.
|
|
160
|
+
- **A picture is an `ImageResult` return, and the one result a model sees rather than reads.**
|
|
161
|
+
`query(ImageResult, { guards: [...] })` (or `[ImageResult]`) returns `{ data, mimeType, caption? }`: `data` is
|
|
162
|
+
padded standard base64 with no `data:` prefix, `mimeType` one of `image/png`, `image/jpeg`, `image/webp`,
|
|
163
|
+
`image/gif` — the types the Anthropic and OpenAI models take. `tools/call` answers the caption as a text block and
|
|
164
|
+
the picture as an image block, one pair per picture, and publishes no `outputSchema` and no `structuredContent`:
|
|
165
|
+
either would repeat the base64, a few hundred KB, as text the model pays for and cannot see. A value that breaks
|
|
166
|
+
the shape is not sent — the dispatcher logs what an `ImageResult` needs and answers the generic failure. Put in
|
|
167
|
+
the caption what the picture cannot say on its own (the region or scale it spans). Over HTTP the endpoint is the
|
|
168
|
+
JSON object, so the app's own client and tests read it as usual; everywhere else an agent reads it — a page
|
|
169
|
+
prompt's resource, the in-page agent — its agent face carries `mimeType` and `caption` only. Nothing caps the
|
|
170
|
+
size, and Anthropic refuses an image over 5 MB, so keep a picture well under that. It is never a model field.
|
|
158
171
|
- **A request schema asks for a relation's id, and a response schema names a nested model.** `serialize` sends a
|
|
159
172
|
relation field as its id and the server never converts an object back, so publishing the related model's shape
|
|
160
173
|
asked an agent for what the document layer cannot take. On the response side every entry inlines the transitive
|
|
161
174
|
closure of every model it mentions (a `$ref` may not cross entries), and on an open catalogue 83% of the bytes
|
|
162
|
-
were the same schemas repeated — so a nested database model is published
|
|
163
|
-
"<ModelName>" }` and the returned model keeps every field of its own; an embedded scalar stays inline. Measured,
|
|
175
|
+
were the same schemas repeated — so by default (`outputSchema: "shallow"`) a nested database model is published
|
|
176
|
+
as `{ type: "object", description: "<ModelName>" }` and the returned model keeps every field of its own; an embedded scalar stays inline. Measured,
|
|
164
177
|
that took a 347-tool listing from 1194KB to 532KB, and one user's paged `tools/list` to 320KB.
|
|
165
178
|
`option.setMcp({ outputSchema: "full" })` (`AKAN_MCP_OUTPUT_SCHEMA`) restores the closure; `"none"` publishes no
|
|
166
179
|
`outputSchema` at all — results still ship as `structuredContent`, and the text block stays on whatever
|
|
@@ -225,15 +238,20 @@ export default page()
|
|
|
225
238
|
- **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
|
|
226
239
|
unparseable, outside its `enumOf`, or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
|
|
227
240
|
published schema and nothing on the wire enforces it — and so does a document that is not there, as
|
|
228
|
-
`No <model> found for the arguments given
|
|
241
|
+
`No <model> found for the arguments given.`, and an MCP App view also receives the 404 `base.error.documentNotFound`
|
|
242
|
+
`Err` a browser would get. A prompt, having no `isError` to carry a refusal, answers `-32602`.
|
|
243
|
+
A document missing on another server this one called (`fetch.clone({ origin })`) arrives as that server's 404
|
|
244
|
+
`base.error.documentNotFound`, so a tool reads its dictionary sentence (`The requested item does not exist.`) and an
|
|
245
|
+
MCP App view receives that `Err`; a prompt whose page reads it is `No screen exists for these arguments.`
|
|
229
246
|
Only a real failure logs a stack; an agent can drive the rest at will.
|
|
230
247
|
- **Three revisions are spoken**: the modern `2026-07-28` and the legacy `2025-11-25` / `2025-06-18`, which are
|
|
231
248
|
wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
|
|
232
249
|
disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
|
|
233
250
|
method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
|
|
234
|
-
gone". **While
|
|
235
|
-
`initialize` settles on `2025-11-25`, because claude.ai renders a view only for a server it reached
|
|
236
|
-
`initialize`.
|
|
251
|
+
gone". **While a `.prompt()` page may show as a view only the legacy pair is spoken**: `server/discover` answers
|
|
252
|
+
`-32601` and `initialize` settles on `2025-11-25`, because claude.ai renders a view only for a server it reached
|
|
253
|
+
through `initialize`. An app that renders no view, or whose RSC worker lists no `.prompt()` page, speaks all three.
|
|
254
|
+
`modern: true` / `AKAN_MCP_MODERN=true` offers `2026-07-28` anyway; `false` withholds it everywhere.
|
|
237
255
|
- **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
|
|
238
256
|
body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
|
|
239
257
|
rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
|
|
@@ -311,9 +329,9 @@ delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthReg
|
|
|
311
329
|
(`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600, also how long a revoked
|
|
312
330
|
access token can outlive its grant), `clientIdMetadata: { enabled, refusePrivateAddresses }` (both on; `enabled:
|
|
313
331
|
false` turns Client ID Metadata Documents off and the metadata stops advertising them — Claude Code then registers
|
|
314
|
-
dynamically instead), `issuer` and `resource` (only when a tunnel or an edge makes the derived origin wrong
|
|
315
|
-
`enabled: false` to take the server and the `/mcp` credential
|
|
316
|
-
honour `?redirect=` to return the user to the consent page.
|
|
332
|
+
dynamically instead), `issuer` and `resource` (only when a tunnel or an edge makes the derived origin wrong, since
|
|
333
|
+
an MCP client compares the issuer byte for byte), `enabled: false` to take the server and the `/mcp` credential
|
|
334
|
+
requirement away together. The signin page must honour `?redirect=` to return the user to the consent page.
|
|
317
335
|
- **Clients:** Claude Code — `claude mcp add --transport http <name> <origin>/mcp` and nothing else; it registers,
|
|
318
336
|
opens the browser to `http://localhost:<port>/callback`, refreshes on 401. claude.ai — a custom connector by URL
|
|
319
337
|
(needs public HTTPS; DCR or the Advanced-settings client id/secret; callback
|
|
@@ -362,7 +380,8 @@ rule: a page taking no arguments is the most useful prompt, since MCP has no com
|
|
|
362
380
|
when the id is already in hand.
|
|
363
381
|
|
|
364
382
|
## MCP Apps — The Page, Rendered In The Chat
|
|
365
|
-
**A `.prompt()` page is also a tool**, of the same name and arguments, marked `readOnlyHint
|
|
383
|
+
**A `.prompt()` page is also a tool**, of the same name and arguments, marked `readOnlyHint: false`, since running
|
|
384
|
+
the page makes its own `fetch.*` calls, mutations included. Its result is the
|
|
366
385
|
prompt's briefing plus `Open this screen in a browser: <url>` — what a client without the
|
|
367
386
|
`io.modelcontextprotocol/ui` extension (Claude Code, Codex) reads. A host that declares the extension (claude.ai,
|
|
368
387
|
Claude Desktop, ChatGPT, VS Code, Goose) is also given `_meta.ui.resourceUri` (`ui://<app>/app`, or
|
|
@@ -371,14 +390,17 @@ in a sandboxed iframe started on the page the tool ran. Its first queries are an
|
|
|
371
390
|
the result's `_meta`, the one part no host shows the model — so the first paint costs no round trip; every later
|
|
372
391
|
call is relayed by the host as `tools/call` under the host's token. Nothing is declared for any of it.
|
|
373
392
|
|
|
374
|
-
- **What a view may call** is every published tool and every endpoint kept off the shelf by `mcp: false` alone
|
|
375
|
-
listed with `visibility: ["app"]` — which a host offers to the view and never to
|
|
376
|
-
|
|
377
|
-
neither those tools nor the `ui://` resources, a
|
|
378
|
-
carries none of the view's data for it.
|
|
393
|
+
- **What a view may call** is every published tool, and every endpoint kept off the shelf by `mcp: false` alone or
|
|
394
|
+
the generated `light<Model>` read, listed with `visibility: ["app"]` — which a host offers to the view and never to
|
|
395
|
+
the model; a `Person` guard still refuses one. Visibility is a host's UX, not authorization: the guards decide, as
|
|
396
|
+
everywhere. A client that did not declare the extension is listed neither those tools nor the `ui://` resources, a
|
|
397
|
+
call to one answers `Unknown tool`, and a page tool's result carries none of the view's data for it.
|
|
379
398
|
- **The capability is read per request.** A legacy client declares it once at `initialize` and is issued an
|
|
380
|
-
`Mcp-Session-Id` (`ui.<
|
|
381
|
-
|
|
399
|
+
`Mcp-Session-Id` (`ui.<nonce>.<signature>`) that stores nothing: the app signs it, and an id the app did not sign is
|
|
400
|
+
served as a client without views. With `modern: true` a modern request carries the capability in `_meta`. A client
|
|
401
|
+
that declares nothing is issued no session, exactly as before.
|
|
402
|
+
- **A read-only deployment has no MCP Apps.** With `readOnly` / `AKAN_MCP_READONLY` there are no page tools, app-only
|
|
403
|
+
tools, views or UI sessions, since running a page makes its own `fetch.*` calls, mutations included.
|
|
382
404
|
- **The view is the page, not the app around it.** Each layout above it still runs — guards, providers,
|
|
383
405
|
`<Agent.Guide>` — but what it draws around `{children}` is hidden, with its frame chrome (`Layout.Navbar`,
|
|
384
406
|
`TopInset`, `BottomInset`, `BottomTab`), the `topInset` / `bottomInset` it reserves and the tools its controls
|
|
@@ -469,7 +491,8 @@ the same guards.** What stays closed is closed because no agent has a reason to
|
|
|
469
491
|
- **Scope is what makes hiding possible.** `static scope: "account"` (a role check) is evaluated with no arguments
|
|
470
492
|
and filters the listing per caller; `"resource"` (`Can<Verb><Model>`, ownership) needs `context.getArg()`, stays
|
|
471
493
|
listed, and stops the call. Marking a resource guard `"account"` would make the listing lie — the marker has no
|
|
472
|
-
default for that reason.
|
|
494
|
+
default for that reason. An `"account"` guard that throws at listing time reached for arguments it does not
|
|
495
|
+
have: the entry is hidden from every caller and the guard is named once in the log as mismarked.
|
|
473
496
|
- **Identity comes from the internal args** (`.with(Self)`, `.with(Account)`, `.with(Me)`), never from an id the
|
|
474
497
|
client sent, and the service re-checks ownership behind the guard.
|
|
475
498
|
- **An admin who signs in on the consent page mints an admin token.** A connector created under an admin account
|
|
@@ -9,7 +9,7 @@ Generate the business data shape for a collection-backed Akan module. This file
|
|
|
9
9
|
- Define `ModelObject` by extending `via(ModelInput, (field) => ({ ... }))` for stored fields controlled by system or service behavior.
|
|
10
10
|
- Define `LightModel` by selecting compact list/reference fields from the object layer.
|
|
11
11
|
- Define full `Model` by combining object and light layers and adding only small static or type helpers when needed.
|
|
12
|
-
-
|
|
12
|
+
- Always define `ModelInsight`, empty when the model has no aggregation or dashboard values yet.
|
|
13
13
|
|
|
14
14
|
## Current Akan Patterns
|
|
15
15
|
- Use `via()` and the `field` helper for all model layers.
|
|
@@ -21,6 +21,7 @@ Generate the business data shape for a collection-backed Akan module. This file
|
|
|
21
21
|
- Keep the light model small enough for lists, cards, and relation previews.
|
|
22
22
|
- Do not put persistence queries, service workflows, fetch calls, or React UI in constant files.
|
|
23
23
|
- Use stable defaults for arrays and frequently read values.
|
|
24
|
+
- Never declare a `static` on the Object or Light class: `via(XObject, LightX, …)` mixes in their prototypes only, so a static there never reaches `X` — put it on `XInput` (or a scalar), `X`, or `XInsight`. `no-static-in-object-light-model` recognises the Object class by its `*Object` name (its `via()` shape is the Insight's) and the Light class by its `Light*` name or its `[...] as const` field tuple.
|
|
24
25
|
- Add dictionary coverage for public fields, enums, insights, and query-facing names.
|
|
25
26
|
|
|
26
27
|
## Review Checklist
|
|
@@ -22,6 +22,15 @@ Generate client state coordination for a module. Store connects generated fetch
|
|
|
22
22
|
- Do not call private or internal signal operations from UI state.
|
|
23
23
|
|
|
24
24
|
## Form Draft Recovery
|
|
25
|
+
- `Load.Edit`, `Model.EditModal` and `Model.New` save the whole `<model>Form` as the user types and offer it back on
|
|
26
|
+
the next open, so a modal closed by accident, a route change, or a killed app costs nothing. The scope is the
|
|
27
|
+
record id for an edit and the seed plus the route for a new form, both keyed to the signed-in user, so another
|
|
28
|
+
record or another parent never loads someone else's half-finished form.
|
|
29
|
+
- A new form's draft is applied on open with a "start over" chip; an edit whose record moved in the meantime is
|
|
30
|
+
*not* applied, and asks. Turn it off with `draft={false}`, or name the scope yourself with `draft="<scope>"` when
|
|
31
|
+
the context is in neither the id nor the seed.
|
|
32
|
+
- The old per-field `cache` / `cacheKey` props are deprecated and store nothing — they covered five control types,
|
|
33
|
+
keyed on the translated label, and restored over server data.
|
|
25
34
|
- `<model>FormDraft`, `restore<Model>FormDraft`, `discard<Model>FormDraft` and `load<Model>FormDraft` are
|
|
26
35
|
generated. Do not hand-write a draft action, and do not write `<model>Form` to browser storage yourself.
|
|
27
36
|
- The draft key carries the signed-in user, derived by hashing the auth token's payload with `iat`, `exp`, `nbf`
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Page Rule — Route Files, The Route Chain, Heads, Lib Pages
|
|
2
|
+
|
|
3
|
+
What a file under `page/` may be, how a route declares its arguments and settings, how heads merge, what a CSR
|
|
4
|
+
bundle shows of a root layout, and how a lib's routes reach an app. `conventions` carries the invariants and the page
|
|
5
|
+
body shape — this is the full contract behind them. Before changing route behavior, check
|
|
6
|
+
`pkgs/akanjs/server/routeTreeBuilder.ts` and nearby routes for the expected pattern.
|
|
7
|
+
|
|
8
|
+
## Route Files
|
|
9
|
+
|
|
10
|
+
- `apps/<app>/page` may contain route modules only. Do not add helper logic or component-only files there.
|
|
11
|
+
- Route source files under `page/` must use `.tsx`. Do not add `logic.ts`, `.js`, or `.jsx` files under `page/`.
|
|
12
|
+
- A route page is either `<routeName>.tsx` (serving `/routeName`) or a directory's `_index.tsx`; layouts use
|
|
13
|
+
`_layout.tsx`; per-route UI overrides use `_overrides.tsx`.
|
|
14
|
+
- Reserved `_*.tsx` route filenames are limited to `_index.tsx`, `_layout.tsx`, and `_overrides.tsx`; do not add
|
|
15
|
+
files like `_Component.tsx` or `_helper.tsx`.
|
|
16
|
+
- Page filenames must not start with an uppercase letter. Move helper components like `Component.tsx` to app `ui`,
|
|
17
|
+
`common`, or `lib` instead.
|
|
18
|
+
- Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
|
|
19
|
+
- `_overrides.tsx` is a logic-free UI-override manifest: imports plus a single
|
|
20
|
+
`export default override({ Slot: AppComponent })` (from `akanjs/ui`), no `"use client"`. It re-skins framework
|
|
21
|
+
`akanjs/ui` components for its route subtree; nested manifests merge over ancestors slot-by-slot (closest wins).
|
|
22
|
+
The slot list is in `componentRule`.
|
|
23
|
+
|
|
24
|
+
## The Route Chain
|
|
25
|
+
|
|
26
|
+
- **A route file exports one thing**: `export default page()…render(…)` for a page, `layout()…render(…)` for a
|
|
27
|
+
`_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting is
|
|
28
|
+
a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()`, `.loading()`; `.notFound()` / `.error()` on
|
|
29
|
+
a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()` / `.layoutStyle()` /
|
|
30
|
+
`.viewport()` on the root layout; `.prompt()` on a page. A named export beside the chain, or `page()` in a
|
|
31
|
+
`_layout.tsx`, fails the build.
|
|
32
|
+
- **A page names every `[x]` segment of its path with `.param("x", Type)`** and reads a query key only through
|
|
33
|
+
`.search("k", Type)`. Values arrive typed — `ID`/`String` → string, `Int`/`Float` → number, `Boolean`, `Date` →
|
|
34
|
+
Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
|
|
35
|
+
search value that fails is dropped. A layout may declare a subset. Names are string literals: `akan sync` reads
|
|
36
|
+
them off the source and refuses a `[projectId]` folder whose page declares no `.param("projectId")`.
|
|
37
|
+
- **`lang` is never declared**: every route sits under `/:lang`, and the locale segment reaches every stage as
|
|
38
|
+
`lang`.
|
|
39
|
+
- **On a web page's first render `st.use.searchParams()` is still the server's empty snapshot**, synced right after
|
|
40
|
+
hydration, so an effect that needs the query on its first run takes the page's `.search()` value as a prop.
|
|
41
|
+
- There is no `loader=` / `render=` page prop and no `PageProps` interface: the chain declares the arguments and
|
|
42
|
+
`.render()` receives them flat, typed, with `lang` on every route and `children` added for a layout.
|
|
43
|
+
|
|
44
|
+
## Heads
|
|
45
|
+
|
|
46
|
+
- **`.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function of the route's args returning it.** One that
|
|
47
|
+
throws renders no head (warned about), so a layout's sign-in redirect still answers. There is no metadata object,
|
|
48
|
+
and analytics is the app's own script, not a stage.
|
|
49
|
+
- **Heads merge root first**: a deeper `.head()` replaces the `<title>`, a `<meta>` with the same `name` /
|
|
50
|
+
`property`, and a canonical / manifest / icon link, and keeps the rest — so a page states only what it changes.
|
|
51
|
+
- A canonical link and `og:url` each name one URL, so a page carries them only from its own `.head()`, never from a
|
|
52
|
+
layout's.
|
|
53
|
+
|
|
54
|
+
## A Root Layout Inside A CSR Bundle
|
|
55
|
+
|
|
56
|
+
- **A CSR bundle (a native app, `?csr=true`) never shows a root layout's own markup.** Pages render into the frame,
|
|
57
|
+
and what the root layout draws around `{children}` stays in a hidden container, so its wrappers cannot stack a
|
|
58
|
+
second screen beside the frame. Its components still mount and their effects run (`<Auth.User />` works).
|
|
59
|
+
- Anything a person must see from a root layout — an overlay, a gate, a banner — renders through
|
|
60
|
+
`createPortal(…, document.body)`, as `<Agent.Chat />` and the dialogs already do. A non-root `_layout.tsx` has no
|
|
61
|
+
such limit.
|
|
62
|
+
|
|
63
|
+
## Lib Pages — `syncPageLibs`
|
|
64
|
+
|
|
65
|
+
- `libs/<lib>/page` follows the same rules and ships routes to apps that opt in with `syncPageLibs` in
|
|
66
|
+
`akan.config.ts`: `true` takes every lib dep that has a `page` folder, an array takes the libs listed, `false` (the
|
|
67
|
+
default) syncs nothing.
|
|
68
|
+
- `akan sync` links those routes into `apps/<app>/page/(libs)/(<lib>)` — once per basePath when the app declares
|
|
69
|
+
subRoutes. The folder is generated and gitignored like `public/libs`; edit the lib source, never the link.
|
|
70
|
+
- Both path segments are route groups, so a lib route mounts at its own path (`libs/<lib>/page/login/_index.tsx`
|
|
71
|
+
serves `/login`). Two synced routes that resolve to the same pattern are a sync-time error.
|
|
72
|
+
|
|
73
|
+
## Build Scope And The Legacy Shape
|
|
74
|
+
|
|
75
|
+
- `.config({ devOnly: true })` keeps a route out of `akan build` while it keeps serving under `akan start` and keeps
|
|
76
|
+
being typechecked. On a `_layout.tsx` it excludes every route under that directory too. Write it as a literal
|
|
77
|
+
`true`/`false` — the build reads it off the source without evaluating the module.
|
|
78
|
+
- The legacy shape — `export default async function Page` beside `pageConfig` / `head` / `Loading` exports — still
|
|
79
|
+
loads and logs one deprecation warning per file at boot. Migrate with the `workspaceRecipes` recipe "Migrating
|
|
80
|
+
`page/` to the route chain"; new files are never written in it.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Query Rule — Filters, Slices, Hydration, Search, Cascade
|
|
2
2
|
|
|
3
|
-
How a model's data is queried, exposed to a client store, searched, and removed. Filters and slices are
|
|
4
|
-
query surfaces; `q.search()` and the `text` field
|
|
5
|
-
with it. `conventions` carries the invariants —
|
|
3
|
+
How a model's data is queried, exposed to a client store, searched, written, and removed. Filters and slices are
|
|
4
|
+
the two query surfaces; the model facade carries the scoped reads and writes; `q.search()` and the `text` field
|
|
5
|
+
roles are the search surface; `cascade` is what a removal takes with it. `conventions` carries the invariants —
|
|
6
|
+
this is the full contract behind them.
|
|
6
7
|
|
|
7
8
|
## Slices, Queries, and Hydration
|
|
8
9
|
|
|
@@ -31,6 +32,9 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
31
32
|
A model with none renders ids and says so in the picker.
|
|
32
33
|
- A slice's `exec` returns a `QueryOf` (an opaque query descriptor, `pkgs/akanjs/constant/types.ts`); you **cannot** chain `.sort()`/`.limit()` on it.
|
|
33
34
|
- Apply ordering/paging via the store `init` fetch option instead: `initX(..., { sort, page, limit })` (`pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts`).
|
|
35
|
+
- **`limit: 0` means every row on the server, never on the client.** A server `list<Filter>(…, { limit: 0 })`
|
|
36
|
+
reads without a ceiling, like an omitted limit, and `null` pages at 20. A client's `0` (`initX(…, { limit: 0 })`)
|
|
37
|
+
is served `MAX_PAGE_SIZE` (500) with a one-time warn. Paginate (`loadMoreOf<Model>`) or name a page size.
|
|
34
38
|
- **Every sort ends with `id`, in the direction of its last key, and a missing value sorts below every other** —
|
|
35
39
|
first ascending, last descending, in every database mode and in the client placing a live row. Rows the declared
|
|
36
40
|
keys tie on (a midpoint priority dragged into one gap until the float runs out, two rows saved in one millisecond)
|
|
@@ -51,8 +55,9 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
51
55
|
- **A projection is bare on the facade and nested under `select` on a filter accessor.** `pickById(id, { secretField:
|
|
52
56
|
true })` / `find(query, { secretField: true })` take the projection as their second argument; a generated
|
|
53
57
|
`findBy<Filter>(...args, { select: { secretField: true } })` takes it inside the option object. The shapes do not
|
|
54
|
-
swap: `{ select: … }` handed to the facade projects a field named `select`, which no model has, and
|
|
55
|
-
|
|
58
|
+
swap: `{ select: … }` handed to the facade projects a field named `select`, which no model has, and throws
|
|
59
|
+
`Unknown document field path`. The facade's third argument is the scope (`{ withRemoved: true }`), never a field.
|
|
60
|
+
This is the only way to read a `field.secret(...)` value, which is otherwise stripped.
|
|
56
61
|
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`), and so do client fetch results — `FetchClient` parses every return with `crystalize` defaulting to **true** (`pkgs/akanjs/fetch/client/fetchClient.ts`). The raw `GetStateObject` shape (functions stripped, `pkgs/akanjs/base/types.ts`) is what the `<ref>Obj` / `<ref>ObjList` / `<ref>ObjInsight` handles carry, because those pass `crystalize: false` on purpose so the value can cross the RSC boundary as a client prop.
|
|
57
62
|
- Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
|
|
58
63
|
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four are
|
|
@@ -81,6 +86,30 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
81
86
|
matches a freshly inserted row and silently stops matching that same row after its first round-trip save. Reach
|
|
82
87
|
for it only to find rows written before the field was declared.
|
|
83
88
|
|
|
89
|
+
## Soft Removal And The Facade's Writes
|
|
90
|
+
|
|
91
|
+
- **Removal is always soft** — the framework has no hard delete for a model table, and `delete` is deliberately
|
|
92
|
+
left unused so it can mean one later. Every read and query-level write sees live rows only (`removedAt IS NULL`).
|
|
93
|
+
- **`{ withRemoved: true }` reaches removed rows**: the third argument of `find` / `findOne` / `findById`
|
|
94
|
+
(projection second, `null` when none), the last of `count` / `exists` / `updateMany`, or inside `updateOne` /
|
|
95
|
+
`updateById`'s options. Without it, a `removedAt` condition only a removed row can meet (`q.exists("removedAt")`,
|
|
96
|
+
a comparison) throws `can never match`.
|
|
97
|
+
- Removes, slices, filter methods, loaders and `pickById` stay live-only, a removed document is read-only
|
|
98
|
+
(`save()` throws), and `updateById(id, { removedAt: null }, { withRemoved: true })` revives one without hooks.
|
|
99
|
+
- **A `pick` that finds no live row throws `NoDocumentError`**, so the generated `get<Model>` of a missing or
|
|
100
|
+
removed id answers an HTTP or websocket caller 404 `base.error.documentNotFound`, and an agent `No <model> found
|
|
101
|
+
for the arguments given.` The message, which names the table and the id or query, stays on the server. Throw
|
|
102
|
+
your own `Err.NotFound` only where a nullable load decides that nothing found is an error.
|
|
103
|
+
- The facade spells `Many`/`One` out on its writes (`updateOne` / `updateMany` / `removeOne` / `removeMany`); only
|
|
104
|
+
the count was shortened to `count(query)`, with `countDocuments` kept as `@deprecated`.
|
|
105
|
+
- **`updateById(id, update)` and `removeById(id)` are those same query-level writes narrowed to one id, not the
|
|
106
|
+
document path**: they fire no hooks, so a model whose removal cascades or carries a `_postRemove` still goes
|
|
107
|
+
through `remove<Model>(id)`.
|
|
108
|
+
- The write-through pair is `pickAndWrite(id, data)` / `pickOneAndWrite(query, data)` — pick the document, write
|
|
109
|
+
into it and save it, so the save hooks do run; reach for those when the write has to be seen by them.
|
|
110
|
+
- `upsertById(data, { preserveTimestamps })` writes a record whole under its own id, new or in place, with no
|
|
111
|
+
hook: the write a sync or a backfill repeats safely, keeping the record's `createdAt`/`updatedAt` when asked.
|
|
112
|
+
|
|
84
113
|
## Text Search In A Filter — `q.search()`
|
|
85
114
|
|
|
86
115
|
- Text search is a filter query node like any other: `bySearch: filter().arg("text", String).query((text, q) =>
|
|
@@ -108,10 +137,12 @@ with it. `conventions` carries the invariants — this is the full contract behi
|
|
|
108
137
|
|
|
109
138
|
- A field joins the full-text index by declaring one of five roles: `field(String, { text: "title" })`, and likewise
|
|
110
139
|
`"desc"`, `"tag"`, `"thumb"`, `"filter"`. Nothing else opts a field in, and there is no per-model switch.
|
|
111
|
-
- Pick the role by what the value *is*, because the rank weights them positionally (`title` 10,
|
|
112
|
-
`filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword
|
|
113
|
-
scoping value (status, owner, role) that must be matchable but must never outrank a real title
|
|
140
|
+
- Pick the role by what the value *is*, because the rank (`bm25` on SQLite) weights them positionally (`title` 10,
|
|
141
|
+
`tag` 3, `desc` 1, `filter` 0): `title` is the one line a human scans for, `desc` is prose, `tag` is a keyword
|
|
142
|
+
list, `filter` is a scoping value (status, owner, role) that must be matchable but must never outrank a real title
|
|
143
|
+
hit.
|
|
114
144
|
- `thumb` is mirrored for rendering a hit and is **not** indexed — never expect it to match.
|
|
145
|
+
- `schema.index({ x: "text" })` (a Mongo text spec) builds nothing and warns at boot: full-text search is this role.
|
|
115
146
|
- **`field.secret`, `field.hidden` and `resolve()` take no `text` role — it is a compile error**, because the
|
|
116
147
|
mirror is plaintext and an indexed secret would leak through search. The class build throws the same refusal as
|
|
117
148
|
a backstop, for an option object the excess-property check cannot see through, and names the way out: drop the
|
|
@@ -203,7 +234,9 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
203
234
|
generated `remove<Filter>` / `update<Filter>`, and the facade's `removeById` / `updateById`. Remove one document
|
|
204
235
|
at a time when it cascades.
|
|
205
236
|
- Cascades are **idempotent**: `removedAt IS NULL` is ANDed into every query-level write, and a remove never takes
|
|
206
|
-
`{ withRemoved: true }`, so an already-removed row keeps its original `removedAt`.
|
|
207
|
-
carried down the whole chain
|
|
208
|
-
|
|
237
|
+
`{ withRemoved: true }`, so an already-removed row keeps its original `removedAt`. Every document a cascade
|
|
238
|
+
removes one at a time joins a visited set carried down the whole chain (a bulk target never does), so a target held
|
|
239
|
+
by two `removeRef` fields (or twice in one array) is removed once and cycles are cut; the depth cap is 16.
|
|
240
|
+
`q.search()` cannot see removed rows (the mirror drops them), so search with the flag throws, as does `upsert` with
|
|
241
|
+
it.
|
|
209
242
|
|