akanjs 3.0.0-alpha.51 → 3.0.0-alpha.53
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/CHANGELOG.md +103 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanApp.ts +15 -11
- package/server/routeTreeBuilder.ts +10 -8
- package/service/predefinedAdaptor/llm.adaptor.ts +0 -1
- package/store/agent/StoreSurfaceSource.ts +1 -5
- package/store/agentic/AgentValue.ts +112 -0
- package/store/agentic/StExposeBuilder.ts +47 -0
- package/store/agentic/StExposeDraft.ts +24 -0
- package/store/agentic/StStateBuilder.ts +85 -0
- package/store/agentic/StStateDraft.ts +23 -0
- package/store/agentic/StToolBuilder.ts +43 -25
- package/store/agentic/StToolDraft.ts +22 -0
- package/store/agentic/attachAgentic.ts +20 -14
- package/store/agentic/index.ts +6 -3
- package/store/agentic/useFieldTool.ts +0 -4
- package/store/agentic/useFormTools.ts +4 -6
- package/store/agentic/useRelationFieldTool.ts +1 -2
- package/types/service/predefinedAdaptor/llm.adaptor.d.ts +0 -1
- package/types/store/agentic/AgentValue.d.ts +35 -0
- package/types/store/agentic/StExposeBuilder.d.ts +21 -0
- package/types/store/agentic/StExposeDraft.d.ts +13 -0
- package/types/store/agentic/StStateBuilder.d.ts +21 -0
- package/types/store/agentic/StStateDraft.d.ts +12 -0
- package/types/store/agentic/StToolBuilder.d.ts +15 -16
- package/types/store/agentic/StToolDraft.d.ts +13 -0
- package/types/store/agentic/attachAgentic.d.ts +17 -11
- package/types/store/agentic/index.d.ts +6 -3
- package/types/store/agentic/useFormTools.d.ts +4 -4
- package/types/vendor/use-agentic/Agentic.d.ts +3 -3
- package/types/vendor/use-agentic/AgenticSurface.d.ts +5 -4
- package/types/vendor/use-agentic/types.d.ts +5 -5
- package/types/vendor/use-agentic/useAgentTool.d.ts +2 -2
- package/ui/Agent/Tool.tsx +1 -15
- package/ui/Data/ListContainer.tsx +20 -42
- package/ui/Dialog/Provider.tsx +7 -6
- package/ui/Dropdown.tsx +7 -9
- package/ui/Layout/Sider.tsx +12 -6
- package/ui/Model/EditModal.tsx +4 -6
- package/ui/Model/EditWrapper.tsx +2 -5
- package/ui/Model/NewWrapper_Client.tsx +1 -2
- package/ui/Model/Remove.tsx +2 -1
- package/ui/Model/RemoveWrapper.tsx +2 -1
- package/ui/Model/SureToRemove.tsx +2 -5
- package/ui/Model/ViewEditModal.tsx +6 -9
- package/ui/Model/ViewModal.tsx +3 -4
- package/ui/Model/ViewWrapper.tsx +2 -1
- package/ui/Popconfirm.tsx +7 -2
- package/ui/ScreenNavigator.tsx +5 -5
- package/ui/Select.tsx +8 -2
- package/ui/System/SelectLanguage.tsx +2 -1
- package/ui/System/ThemeToggle.tsx +2 -1
- package/ui/Tab/Provider.tsx +6 -7
- package/ui/index.ts +1 -0
- package/vendor/use-agentic/AgentSession.ts +1 -2
- package/vendor/use-agentic/Agentic.tsx +4 -4
- package/vendor/use-agentic/AgenticSurface.ts +16 -10
- package/vendor/use-agentic/WIRE.md +0 -1
- package/vendor/use-agentic/types.ts +5 -6
- package/vendor/use-agentic/useAgentState.ts +0 -1
- package/vendor/use-agentic/useAgentTool.ts +3 -3
- package/webkit/bootCsr.tsx +10 -8
- package/webkit/usePageTool.tsx +5 -7
- package/store/agentic/readableValue.ts +0 -17
- package/store/agentic/useStExpose.ts +0 -18
- package/store/agentic/useStState.ts +0 -49
- package/types/store/agentic/readableValue.d.ts +0 -3
- package/types/store/agentic/useStExpose.d.ts +0 -8
- package/types/store/agentic/useStState.d.ts +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,108 @@
|
|
|
1
1
|
# akanjs
|
|
2
2
|
|
|
3
|
+
## 2.4.2
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 11aa655: feat(constant): remove a relation's target with its owner via `cascade: "remove"`
|
|
8
|
+
|
|
9
|
+
A relation field can now take its target down with it: `image: field(File, { cascade: "remove" })`, arrays
|
|
10
|
+
included. The removal runs through the **target's service**, so the target's own `_postRemove` runs with it —
|
|
11
|
+
that is how removing a model also deletes the file's stored blob or object, with no extra wiring in the owning
|
|
12
|
+
module.
|
|
13
|
+
|
|
14
|
+
Only a relation accepts the option. A `String`, an `ID`, a scalar, and a nested array each fail while the class
|
|
15
|
+
is being built, naming the field: none of them points at a document the framework could remove.
|
|
16
|
+
|
|
17
|
+
Target services resolve lazily, at removal time, so a cascade adds no boot-order edge between two services and a
|
|
18
|
+
cascade cycle cannot fail the boot. They resolve _before_ the parent is touched, so a model cascading into a
|
|
19
|
+
module the app never mounted fails with nothing half-removed.
|
|
20
|
+
|
|
21
|
+
Two limits worth knowing. Nothing checks whether another document still references the same target, so declaring
|
|
22
|
+
`cascade` asserts that the field owns its target exclusively. And query-level removal (`deleteManyByQuery` /
|
|
23
|
+
`updateManyByQuery`) stamps `removedAt` in one atomic update that fires no hooks and therefore no cascade —
|
|
24
|
+
remove documents one at a time when they cascade.
|
|
25
|
+
|
|
26
|
+
- 11aa655: feat(store): invalidate sibling slices on create and refetch them in `Load.Units`
|
|
27
|
+
|
|
28
|
+
A model with more than one parent is listed by more than one slice, and a create could only ever be spliced
|
|
29
|
+
into the slice it was issued from. Creating a `bizDoc` from `bizDocInOrg` left `bizDocListInProject` without
|
|
30
|
+
it, and an RSC navigation back to that page replayed a cached payload whose server-stamped
|
|
31
|
+
`bizDocInitAtInProject` still satisfied `Load.Units`' cache check — so the new document stayed invisible until
|
|
32
|
+
a full reload.
|
|
33
|
+
|
|
34
|
+
`create<Model>` and `create<Model>InForm` now stamp a new per-slice `<model>StaleAt<Suffix>` on every slice
|
|
35
|
+
_except_ the one named by `sliceName`. Whether the new document belongs to a sibling slice is a server-side
|
|
36
|
+
filter decision, so the siblings are marked for revalidation rather than patched optimistically. `Load.Units`
|
|
37
|
+
refetches through `refresh<Model><Suffix>` when its slice's `StaleAt` is newer than its `InitAt`, and a
|
|
38
|
+
completed refresh restamps `InitAt` past `StaleAt` to end the stale state. The refetch is skipped while the
|
|
39
|
+
list is already loading, which also dedups several `Load.Units` mounted on one slice.
|
|
40
|
+
|
|
41
|
+
`Load.Units` also takes a `staleTime` prop, in milliseconds, for age-based revalidation independent of any
|
|
42
|
+
create: `staleTime={0}` always refetches on mount, `staleTime={30_000}` refetches only when the cached data is
|
|
43
|
+
older than 30s. Omitting it leaves the component purely invalidation-driven.
|
|
44
|
+
|
|
45
|
+
Update and remove are unchanged — they already walk every slice, because a document they touch is one the
|
|
46
|
+
cached lists can be searched for by id.
|
|
47
|
+
|
|
48
|
+
- 11aa655: feat(server): add subRoute hosts at runtime with `AKAN_SUB_ROUTE_HOSTS`
|
|
49
|
+
|
|
50
|
+
An app resolved a request Host to a basePath through a map fixed at build time — the domains written into
|
|
51
|
+
`akan.config.ts` plus the generated `<basePath>-<branch>.<serveDomain>`. A platform that mints its hostnames when
|
|
52
|
+
a project is created cannot write either one into the repo, so its subRoute hosts fell through to the root app.
|
|
53
|
+
|
|
54
|
+
`AKAN_SUB_ROUTE_HOSTS` adds to that map at boot, in the same spirit as `AKAN_PUBLIC_BASE_PATHS`:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
AKAN_SUB_ROUTE_HOSTS="soft=soft-abc.try.akanjs.com,soft.acme.com;office=office-abc.try.akanjs.com"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Hosts are matched lowercased and without a port, exactly as the built-in ones are. The env mapping is a union with
|
|
61
|
+
the built one, never a replacement, so a tenant's own domains keep working and removing the env restores the
|
|
62
|
+
previous behaviour byte for byte.
|
|
63
|
+
|
|
64
|
+
A basePath the build does not serve is dropped with a warning rather than honoured — the route tree is a build
|
|
65
|
+
output, so accepting one would answer every request under it with a 404 and nothing to explain why. A malformed
|
|
66
|
+
entry is skipped the same way: the value is rendered by a deployment platform, and one bad character must not turn
|
|
67
|
+
into a boot loop across every pod.
|
|
68
|
+
|
|
69
|
+
`x-base-path` is now checked against the basePaths the build serves before it is trusted, matching the check
|
|
70
|
+
`getBasePathFromPathname` already applied to it. An unrecognised value falls through to host matching instead of
|
|
71
|
+
selecting a basePath that resolves to nothing.
|
|
72
|
+
|
|
73
|
+
- 25d5b15: feat(devkit): sync lib `page` trees into apps and honor `pageConfig.devOnly`
|
|
74
|
+
|
|
75
|
+
Apps can opt in with `syncPageLibs` (`true` / lib list) so `akan sync` links each lib's routes under
|
|
76
|
+
`page/(libs)/(<lib>)` — once per basePath when the app has subRoutes. The link is generated and
|
|
77
|
+
gitignored; the lib source stays the edit target. Collision on the resolved route pattern is a
|
|
78
|
+
sync-time error.
|
|
79
|
+
|
|
80
|
+
`pageConfig.devOnly: true` (literal only) keeps a route out of `akan build` while `akan start` still
|
|
81
|
+
serves it. On a `_layout` it excludes the whole subtree. Symlink-aware file ops avoid wiping a synced
|
|
82
|
+
lib when cleaning an app link, and dangling lib links no longer break `getApps`.
|
|
83
|
+
|
|
84
|
+
- 25d5b15: feat(signal): refresh websocket credentials in-session and re-check pubsub rooms
|
|
85
|
+
|
|
86
|
+
Handshake credentials now live on `AppWsData` (headers/cookies only). Clients that hold the token in
|
|
87
|
+
memory send it with `fetch.setJwt(...)`, which forwards an `__auth` frame; the server swaps the
|
|
88
|
+
snapshot synchronously and re-runs each subscribed room's guards, unsubscribing the ones that fail.
|
|
89
|
+
|
|
90
|
+
Guards should read the caller with `context.get("account")` instead of branching on HTTP vs websocket
|
|
91
|
+
transport. Slice-level guards still only wrap generated query/mutation endpoints — a pubsub endpoint
|
|
92
|
+
needs its own `guards` if the room itself must be protected.
|
|
93
|
+
|
|
94
|
+
### Patch Changes
|
|
95
|
+
|
|
96
|
+
- 6d58c7e: add search feature for sqlit database with fts5
|
|
97
|
+
- 25d5b15: fix(ui): refresh EditModal when the hydrated edit payload is stale
|
|
98
|
+
|
|
99
|
+
RSC navigation can replay a cached page tree, and an edit shell hydrated from that payload can show
|
|
100
|
+
arbitrarily old form data. EditModal now treats a cache replay (or a `modelViewAt` older than 60s) as
|
|
101
|
+
stale and refetches via `edit<Model>` while keeping the modal open on the last known id.
|
|
102
|
+
|
|
103
|
+
- 42cf7a2: Normalize unsendable relayed WebSocket close codes to 1001 so Bun's global client WebSocket.close no longer throws InvalidAccessError at the gateway's client-to-upstream relay when a peer disappears without a close frame (code 1006). The upstream-to-client relay applies the same normalization defensively.
|
|
104
|
+
- 04cb46d: Allow the documented wsConnect export during source validation for root layouts, including base-path and grouped root boundaries, while continuing to reject it on nested layouts.
|
|
105
|
+
|
|
3
106
|
## 2.4.1
|
|
4
107
|
|
|
5
108
|
### Patch Changes
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -45,6 +45,19 @@ type GatewayUpstream = {
|
|
|
45
45
|
ws?: Extract<AkanUpstream, { type: "tcp" }>;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Received-only close codes cannot be sent in a close frame. The gateway deliberately normalizes
|
|
50
|
+
* every unsendable code, including semantically distinct 1005 and 1006 events, to 1001 in both
|
|
51
|
+
* relay directions. In particular, Bun's global client `WebSocket.close()` throws an
|
|
52
|
+
* InvalidAccessError for these codes at the client-to-upstream relay; normalization at the
|
|
53
|
+
* upstream-to-client `Bun.ServerWebSocket.close()` relay is defensive and keeps behavior symmetric.
|
|
54
|
+
*/
|
|
55
|
+
const relayableCloseCode = (code: number): number => {
|
|
56
|
+
if (code >= 3000 && code <= 4999) return code;
|
|
57
|
+
if (code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
|
|
58
|
+
return 1001;
|
|
59
|
+
};
|
|
60
|
+
|
|
48
61
|
/** Options for the Akan gateway that launches child server replicas and listens for traffic. */
|
|
49
62
|
export interface AkanAppOptions {
|
|
50
63
|
replica?: number | string;
|
|
@@ -602,9 +615,7 @@ export class AkanApp {
|
|
|
602
615
|
if (result === 0) upstream.close();
|
|
603
616
|
});
|
|
604
617
|
upstream.addEventListener("close", (event) => {
|
|
605
|
-
|
|
606
|
-
if (code === undefined) ws.close();
|
|
607
|
-
else ws.close(code, event.reason);
|
|
618
|
+
ws.close(relayableCloseCode(event.code), event.reason);
|
|
608
619
|
});
|
|
609
620
|
upstream.addEventListener("error", () => ws.close(1011, "upstream websocket error"));
|
|
610
621
|
Object.assign(ws.data, { pending });
|
|
@@ -625,15 +636,8 @@ export class AkanApp {
|
|
|
625
636
|
else ws.data.pending?.push(payload as string | ArrayBuffer);
|
|
626
637
|
}
|
|
627
638
|
|
|
628
|
-
static #sendableCloseCode(code: number): number | undefined {
|
|
629
|
-
const sendable = (code >= 1000 && code <= 1014 && (code < 1004 || code > 1006)) || (code >= 3000 && code <= 4999);
|
|
630
|
-
return sendable ? code : undefined;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
639
|
#handleWsClose(ws: Bun.ServerWebSocket<GatewayWsData>, code: number, reason: string) {
|
|
634
|
-
|
|
635
|
-
if (upstreamCode === undefined) ws.data.upstream.close();
|
|
636
|
-
else ws.data.upstream.close(upstreamCode, reason);
|
|
640
|
+
ws.data.upstream.close(relayableCloseCode(code), reason);
|
|
637
641
|
const child = this.#children.get(ws.data.childIdx);
|
|
638
642
|
if (child) child.metrics.activeWebSockets = Math.max(0, (child.metrics.activeWebSockets ?? 1) - 1);
|
|
639
643
|
}
|
|
@@ -191,6 +191,7 @@ export class RouteTreeBuilder {
|
|
|
191
191
|
parentLayouts: RouteRender[] = [],
|
|
192
192
|
parentPaths: string[] = [],
|
|
193
193
|
parentHead?: ResolveHead,
|
|
194
|
+
parentOverrides: RouteRender[] = [],
|
|
194
195
|
): PathRoute[] {
|
|
195
196
|
const parentPath = parentPaths.filter((p) => p !== "/").join("");
|
|
196
197
|
const currentPathSegment = /^\/\(.*\)$/.test(route.path) ? "" : route.path;
|
|
@@ -201,8 +202,10 @@ export class RouteTreeBuilder {
|
|
|
201
202
|
const currentLayout = !isRoot && route.renderLayout ? route.renderLayout : null;
|
|
202
203
|
|
|
203
204
|
const currentOverrideRenders = route.renderOverrides ? [route.renderOverrides] : [];
|
|
204
|
-
const
|
|
205
|
-
const
|
|
205
|
+
const overrideRenders = [...parentOverrides, ...currentOverrideRenders];
|
|
206
|
+
const rootLayoutStack = [...parentRootLayouts, ...(currentRootLayout ? [currentRootLayout] : [])];
|
|
207
|
+
const renderRootLayouts = [...overrideRenders, ...rootLayoutStack];
|
|
208
|
+
const renderLayouts = [...parentLayouts, ...(currentLayout ? [currentLayout] : [])];
|
|
206
209
|
if (route.renderLayout) {
|
|
207
210
|
this.#fallbackRoutes.push({
|
|
208
211
|
path: routePath,
|
|
@@ -213,11 +216,10 @@ export class RouteTreeBuilder {
|
|
|
213
216
|
}
|
|
214
217
|
const routeHead = RouteTreeBuilder.#composeHeadResolvers(route.renderLayout?.resolveHead, parentHead);
|
|
215
218
|
const pageRenderRootLayouts =
|
|
216
|
-
route.pageIncludesOwnLayout === false && currentRootLayout
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
: renderLayouts;
|
|
219
|
+
route.pageIncludesOwnLayout === false && currentRootLayout
|
|
220
|
+
? [...overrideRenders, ...parentRootLayouts]
|
|
221
|
+
: renderRootLayouts;
|
|
222
|
+
const pageRenderLayouts = route.pageIncludesOwnLayout === false && currentLayout ? parentLayouts : renderLayouts;
|
|
221
223
|
const pageHead = route.pageIncludesOwnLayout === false ? parentHead : routeHead;
|
|
222
224
|
return [
|
|
223
225
|
...(route.renderPage
|
|
@@ -236,7 +238,7 @@ export class RouteTreeBuilder {
|
|
|
236
238
|
: []),
|
|
237
239
|
...(route.children.size
|
|
238
240
|
? [...route.children.values()].flatMap((child) =>
|
|
239
|
-
this.#getPathRoutes(child,
|
|
241
|
+
this.#getPathRoutes(child, rootLayoutStack, renderLayouts, pathSegments, routeHead, overrideRenders),
|
|
240
242
|
)
|
|
241
243
|
: []),
|
|
242
244
|
];
|
|
@@ -56,7 +56,6 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
56
56
|
required: ["path"],
|
|
57
57
|
additionalProperties: false,
|
|
58
58
|
},
|
|
59
|
-
effect: "state",
|
|
60
59
|
|
|
61
60
|
guard: (args) =>
|
|
62
61
|
typeof args.path === "string" && args.path.startsWith("/") && !args.path.startsWith("//")
|
|
@@ -83,7 +82,6 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
83
82
|
description:
|
|
84
83
|
"Go back to the previous page in this session's history. Use it to undo a navigation; use navigate for a path.",
|
|
85
84
|
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
86
|
-
effect: "state",
|
|
87
85
|
|
|
88
86
|
guard: () => (router.canGoBack() ? true : "There is no previous page in this session's history."),
|
|
89
87
|
run: async () => {
|
|
@@ -112,7 +110,7 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
112
110
|
},
|
|
113
111
|
additionalProperties: false,
|
|
114
112
|
},
|
|
115
|
-
|
|
113
|
+
settle: false,
|
|
116
114
|
run: (args) => {
|
|
117
115
|
const root = StoreSurfaceSource.#zoneRoot(viewKey);
|
|
118
116
|
const section = typeof args.section === "string" ? args.section.trim() : "";
|
|
@@ -149,7 +147,6 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
149
147
|
required: ["target"],
|
|
150
148
|
additionalProperties: false,
|
|
151
149
|
},
|
|
152
|
-
effect: "state",
|
|
153
150
|
run: (args) => {
|
|
154
151
|
const name = typeof args.target === "string" ? args.target.trim() : "";
|
|
155
152
|
if (!name) throw new Error("highlight needs a target.");
|
|
@@ -182,7 +179,6 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
182
179
|
required: ["key"],
|
|
183
180
|
additionalProperties: false,
|
|
184
181
|
},
|
|
185
|
-
effect: "state",
|
|
186
182
|
run: (args: Record<string, unknown>) => {
|
|
187
183
|
this.#bridge ??= AgentBridge.of();
|
|
188
184
|
return this.#bridge.read(String(args.key), viewKey);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CLIENT_VALUE,
|
|
3
|
+
type Cls,
|
|
4
|
+
type Dayjs,
|
|
5
|
+
dayjs,
|
|
6
|
+
type EnumInstance,
|
|
7
|
+
type FIELD_META,
|
|
8
|
+
type GetStateObject,
|
|
9
|
+
isEnum,
|
|
10
|
+
PrimitiveRegistry,
|
|
11
|
+
type PrimitiveScalar,
|
|
12
|
+
type UnCls,
|
|
13
|
+
} from "akanjs/base";
|
|
14
|
+
import { type ConstantModelRef, type MaskModel, mask, maskFieldsOf } from "akanjs/constant";
|
|
15
|
+
|
|
16
|
+
type AgentSingleType = typeof PrimitiveScalar | EnumInstance<string, any> | ConstantModelRef;
|
|
17
|
+
|
|
18
|
+
/** What a readable declaration names its value as. One level of array, because a published value is one JSON shape. */
|
|
19
|
+
export type AgentFieldType = AgentSingleType | AgentSingleType[];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A model class resolves to its state object rather than its instance type, so a component may hand over either
|
|
23
|
+
* the hydrated document or the plain data copied out of one — masking reads the model that was named, not the
|
|
24
|
+
* class the value still carries.
|
|
25
|
+
*/
|
|
26
|
+
export type AgentValueOf<T> = T extends readonly (infer F)[]
|
|
27
|
+
? AgentValueOf<F>[]
|
|
28
|
+
: T extends { refName: "Any" }
|
|
29
|
+
? unknown
|
|
30
|
+
: T extends EnumInstance<string, infer V>
|
|
31
|
+
? V
|
|
32
|
+
: T extends DateConstructor
|
|
33
|
+
? Dayjs | Date | string
|
|
34
|
+
: T extends { [FIELD_META]: unknown }
|
|
35
|
+
? GetStateObject<UnCls<T>>
|
|
36
|
+
: T extends { [CLIENT_VALUE]: infer V }
|
|
37
|
+
? V
|
|
38
|
+
: unknown;
|
|
39
|
+
|
|
40
|
+
type ValueKind = "any" | "date" | "scalar" | "enum" | "model";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Turns a declared type into what an agent may read of a value of that type.
|
|
44
|
+
*
|
|
45
|
+
* The type is the whole declaration: it typechecks what the component hands over, and it decides how the value is
|
|
46
|
+
* rendered — a model class masks by that model, a `Date` leaves as an ISO string, a scalar passes. `Any` is the
|
|
47
|
+
* escape hatch and passes the value untouched, so a payload nobody modeled stays publishable and the caller owns
|
|
48
|
+
* whether it is JSON and whether it is worth its tokens.
|
|
49
|
+
*/
|
|
50
|
+
export class AgentValue {
|
|
51
|
+
static serialize(type: AgentFieldType, value: unknown): unknown {
|
|
52
|
+
const single = Array.isArray(type) ? type[0] : type;
|
|
53
|
+
if (Array.isArray(type) && Array.isArray(value)) return value.map((item) => AgentValue.#one(single, item));
|
|
54
|
+
return AgentValue.#one(single, value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reports an unreadable type the way `st.tool` reports an undescribable argument — on the console, and the
|
|
59
|
+
* declaration goes unpublished. Throwing would cost the route its server render over an agent-tooling mistake.
|
|
60
|
+
*/
|
|
61
|
+
static publishable(owner: string, type: AgentFieldType): boolean {
|
|
62
|
+
try {
|
|
63
|
+
AgentValue.#kindOf(Array.isArray(type) ? type[0] : type);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(`${owner} is not published: its type is ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static #one(type: AgentSingleType, value: unknown): unknown {
|
|
72
|
+
if (value === null || value === undefined) return value;
|
|
73
|
+
switch (AgentValue.#kindOf(type)) {
|
|
74
|
+
case "date": {
|
|
75
|
+
const parsed = dayjs(value as string | number | Date);
|
|
76
|
+
return parsed.isValid() ? parsed.toISOString() : null;
|
|
77
|
+
}
|
|
78
|
+
case "model":
|
|
79
|
+
return mask(type as MaskModel, value);
|
|
80
|
+
default:
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static #kindOf(type: AgentSingleType): ValueKind {
|
|
86
|
+
if (isEnum(type as Cls)) return "enum";
|
|
87
|
+
if (PrimitiveRegistry.has(type as unknown as Cls)) {
|
|
88
|
+
const refName = PrimitiveRegistry.getName(type as typeof PrimitiveScalar);
|
|
89
|
+
switch (refName) {
|
|
90
|
+
case "Any":
|
|
91
|
+
return "any";
|
|
92
|
+
case "Date":
|
|
93
|
+
return "date";
|
|
94
|
+
case "ID":
|
|
95
|
+
case "String":
|
|
96
|
+
case "Int":
|
|
97
|
+
case "Float":
|
|
98
|
+
case "Boolean":
|
|
99
|
+
return "scalar";
|
|
100
|
+
default:
|
|
101
|
+
throw new Error(`the scalar ${refName}, which an agent cannot read.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (maskFieldsOf(type as MaskModel)) return "model";
|
|
105
|
+
throw new Error(`${AgentValue.#typeName(type)}, and a readable value is a scalar, an enum, a model, or Any.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static #typeName(type: AgentSingleType): string {
|
|
109
|
+
const named = type as { name?: string } | null | undefined;
|
|
110
|
+
return named?.name ? `the type ${named.name}` : `${String(type)}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useAgentResource } from "../../vendor/use-agentic";
|
|
2
|
+
|
|
3
|
+
import { useRef } from "../hooks";
|
|
4
|
+
import { type AgentFieldType, AgentValue, type AgentValueOf } from "./AgentValue";
|
|
5
|
+
|
|
6
|
+
export interface StExposeMeta {
|
|
7
|
+
/** `false` keeps the key out of post-call diff reports — for values that change on their own every second. */
|
|
8
|
+
report?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A read-only value past its description, waiting for the value itself. `.value()` is the one hook.
|
|
13
|
+
*
|
|
14
|
+
* The declared type is what makes the read safe: it typechecks what the component hands over and it decides how
|
|
15
|
+
* the value is rendered, so a model's `hidden` and `secret` fields are stripped by the model that was named
|
|
16
|
+
* rather than by whatever class the value still happens to carry.
|
|
17
|
+
*/
|
|
18
|
+
export class StExposeBuilder<T extends AgentFieldType> {
|
|
19
|
+
readonly #name: string | null;
|
|
20
|
+
readonly #type: T;
|
|
21
|
+
readonly #desc: string;
|
|
22
|
+
readonly #meta: StExposeMeta;
|
|
23
|
+
|
|
24
|
+
constructor(name: string | null, type: T, desc: string, meta: StExposeMeta = {}) {
|
|
25
|
+
this.#name = name;
|
|
26
|
+
this.#type = type;
|
|
27
|
+
this.#desc = desc;
|
|
28
|
+
this.#meta = meta;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A thunk is read when the agent reads, which is the difference that matters for a value assembled out of a ref
|
|
33
|
+
* the children fill in after this render — computing it here would publish whatever was there before they ran.
|
|
34
|
+
*/
|
|
35
|
+
value(value: AgentValueOf<T> | (() => AgentValueOf<T>) | null | undefined): void {
|
|
36
|
+
const declared = useRef<{ name: string | null } | null>(null);
|
|
37
|
+
declared.current ??= {
|
|
38
|
+
name: this.#name && AgentValue.publishable(`st.expose("${this.#name}")`, this.#type) ? this.#name : null,
|
|
39
|
+
};
|
|
40
|
+
useAgentResource(declared.current.name, value, {
|
|
41
|
+
description: this.#desc,
|
|
42
|
+
report: this.#meta.report,
|
|
43
|
+
serialize: (current) =>
|
|
44
|
+
AgentValue.serialize(this.#type, typeof current === "function" ? (current as () => unknown)() : current),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AgentFieldType } from "./AgentValue";
|
|
2
|
+
import { StExposeBuilder, type StExposeMeta } from "./StExposeBuilder";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A readable value before it has said what it is: `st.expose("x", ID).desc("…")`.
|
|
6
|
+
*
|
|
7
|
+
* A falsy name declares nothing and publishes nothing, so a component whose publication is conditional keeps a
|
|
8
|
+
* constant hook count instead of branching around the chain.
|
|
9
|
+
*/
|
|
10
|
+
export class StExposeDraft<T extends AgentFieldType> {
|
|
11
|
+
readonly #name: string | null;
|
|
12
|
+
readonly #type: T;
|
|
13
|
+
readonly #meta: StExposeMeta;
|
|
14
|
+
|
|
15
|
+
constructor(name: string | null, type: T, meta: StExposeMeta = {}) {
|
|
16
|
+
this.#name = name;
|
|
17
|
+
this.#type = type;
|
|
18
|
+
this.#meta = meta;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
desc(text: string): StExposeBuilder<T> {
|
|
22
|
+
return new StExposeBuilder(this.#name, this.#type, text, this.#meta);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { capitalize } from "akanjs/common";
|
|
2
|
+
import type { ParamFieldType } from "akanjs/constant";
|
|
3
|
+
import type { Dispatch, SetStateAction } from "react";
|
|
4
|
+
import { type JsonSchema, useAgentState } from "../../vendor/use-agentic";
|
|
5
|
+
|
|
6
|
+
import { useRef } from "../hooks";
|
|
7
|
+
import { type AgentFieldType, AgentValue, type AgentValueOf } from "./AgentValue";
|
|
8
|
+
import { StToolBuilder } from "./StToolBuilder";
|
|
9
|
+
|
|
10
|
+
export interface StStateMeta {
|
|
11
|
+
/** `false` keeps the key out of post-call diff reports — for values that change on their own every second. */
|
|
12
|
+
report?: boolean;
|
|
13
|
+
/** Publishes a `set<Name>` tool writing the type this state declares. Read-only without it. */
|
|
14
|
+
set?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface StStateDeclaration {
|
|
18
|
+
name: string | null;
|
|
19
|
+
set: { schema: JsonSchema; type: ParamFieldType } | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Local state past its description, waiting for its initial value. `.init()` is the one hook and returns what
|
|
24
|
+
* `useState` returns.
|
|
25
|
+
*
|
|
26
|
+
* The declared type does both halves: it renders the read, and — with `set` — it is the schema of the setter tool
|
|
27
|
+
* an agent writes through. A type nothing can describe costs the write, not the read and not the render.
|
|
28
|
+
*/
|
|
29
|
+
export class StStateBuilder<T extends AgentFieldType> {
|
|
30
|
+
readonly #name: string | null;
|
|
31
|
+
readonly #type: T;
|
|
32
|
+
readonly #desc: string;
|
|
33
|
+
readonly #meta: StStateMeta;
|
|
34
|
+
|
|
35
|
+
constructor(name: string | null, type: T, desc: string, meta: StStateMeta = {}) {
|
|
36
|
+
this.#name = name;
|
|
37
|
+
this.#type = type;
|
|
38
|
+
this.#desc = desc;
|
|
39
|
+
this.#meta = meta;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
init(
|
|
43
|
+
initial: AgentValueOf<T> | (() => AgentValueOf<T>),
|
|
44
|
+
): [AgentValueOf<T>, Dispatch<SetStateAction<AgentValueOf<T>>>];
|
|
45
|
+
init(
|
|
46
|
+
initial: AgentValueOf<T> | null | (() => AgentValueOf<T> | null),
|
|
47
|
+
): [AgentValueOf<T> | null, Dispatch<SetStateAction<AgentValueOf<T> | null>>];
|
|
48
|
+
|
|
49
|
+
init(initial: AgentValueOf<T> | null | (() => AgentValueOf<T> | null)): unknown {
|
|
50
|
+
type Value = AgentValueOf<T> | null;
|
|
51
|
+
const name = this.#name;
|
|
52
|
+
const type = this.#type;
|
|
53
|
+
const declared = useRef<StStateDeclaration | null>(null);
|
|
54
|
+
declared.current ??= {
|
|
55
|
+
name: name && AgentValue.publishable(`st.useState("${name}")`, type) ? name : null,
|
|
56
|
+
set: name && this.#meta.set ? StStateBuilder.#writable(name, type) : null,
|
|
57
|
+
};
|
|
58
|
+
const { set } = declared.current;
|
|
59
|
+
return useAgentState<Value>(declared.current.name, initial, {
|
|
60
|
+
description: this.#desc,
|
|
61
|
+
report: this.#meta.report,
|
|
62
|
+
serialize: (value) => AgentValue.serialize(type, value),
|
|
63
|
+
...(set
|
|
64
|
+
? {
|
|
65
|
+
set: set.schema,
|
|
66
|
+
parse: (value) =>
|
|
67
|
+
StToolBuilder.checkedValue(`set${capitalize(name ?? "")}`, "value", set.type, value) as Value,
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A `set` an agent could only call wrong leaves the key readable — the same trade `st.tool`'s `.arg` makes. */
|
|
74
|
+
static #writable(name: string, type: AgentFieldType): StStateDeclaration["set"] {
|
|
75
|
+
const scalar = type as unknown as ParamFieldType;
|
|
76
|
+
try {
|
|
77
|
+
return { schema: StToolBuilder.schemaOf(scalar), type: scalar };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error(
|
|
80
|
+
`st.useState("${name}") stays read-only: writing ${error instanceof Error ? error.message : String(error)}`,
|
|
81
|
+
);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentFieldType } from "./AgentValue";
|
|
2
|
+
import { StStateBuilder, type StStateMeta } from "./StStateBuilder";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Local state before it has said what it is: `st.useState("tab", String, { set: true }).desc("…")`.
|
|
6
|
+
*
|
|
7
|
+
* A falsy name keeps the state and publishes nothing, so a conditional surface never changes the hook count.
|
|
8
|
+
*/
|
|
9
|
+
export class StStateDraft<T extends AgentFieldType> {
|
|
10
|
+
readonly #name: string | null;
|
|
11
|
+
readonly #type: T;
|
|
12
|
+
readonly #meta: StStateMeta;
|
|
13
|
+
|
|
14
|
+
constructor(name: string | null, type: T, meta: StStateMeta = {}) {
|
|
15
|
+
this.#name = name;
|
|
16
|
+
this.#type = type;
|
|
17
|
+
this.#meta = meta;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
desc(text: string): StStateBuilder<T> {
|
|
21
|
+
return new StStateBuilder(this.#name, this.#type, text, this.#meta);
|
|
22
|
+
}
|
|
23
|
+
}
|