akanjs 3.0.0-alpha.51 → 3.0.0-alpha.52
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
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
|
}
|
package/ui/Popconfirm.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { cn, usePage } from "akanjs/client";
|
|
3
3
|
import { useEscapeKey } from "akanjs/webkit";
|
|
4
|
-
import { type ButtonHTMLAttributes, type ReactNode, useRef, useState } from "react";
|
|
4
|
+
import { type ButtonHTMLAttributes, type ReactNode, useEffect, useRef, useState } from "react";
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
6
6
|
import { BiMessageRoundedError } from "react-icons/bi";
|
|
7
7
|
import { buttonRecipe } from "./Button";
|
|
@@ -54,6 +54,8 @@ export const DefaultPopconfirm = ({
|
|
|
54
54
|
const { l } = usePage();
|
|
55
55
|
const recipe = useUiRecipe("button") ?? buttonRecipe;
|
|
56
56
|
const [isConfirming, setIsConfirming] = useState(false);
|
|
57
|
+
|
|
58
|
+
const [portal, setPortal] = useState<HTMLElement | null>(null);
|
|
57
59
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
58
60
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
59
61
|
|
|
@@ -74,6 +76,9 @@ export const DefaultPopconfirm = ({
|
|
|
74
76
|
setIsConfirming(false);
|
|
75
77
|
};
|
|
76
78
|
useEscapeKey(isConfirming, handleCancel);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
setPortal(document.body);
|
|
81
|
+
}, []);
|
|
77
82
|
|
|
78
83
|
const panel = (
|
|
79
84
|
<>
|
|
@@ -145,7 +150,7 @@ export const DefaultPopconfirm = ({
|
|
|
145
150
|
>
|
|
146
151
|
{children}
|
|
147
152
|
</div>
|
|
148
|
-
{isConfirming &&
|
|
153
|
+
{isConfirming && portal ? createPortal(panel, portal) : null}
|
|
149
154
|
</div>
|
|
150
155
|
);
|
|
151
156
|
};
|
package/ui/Select.tsx
CHANGED
|
@@ -107,6 +107,8 @@ const DefaultSelect = <
|
|
|
107
107
|
|
|
108
108
|
const [selectedValues, setSelectedValues] = useState<T[]>(multiple ? (value as T[]) : [value as T]);
|
|
109
109
|
const [searchText, setSearchText] = useState("");
|
|
110
|
+
|
|
111
|
+
const [portal, setPortal] = useState<HTMLElement | null>(null);
|
|
110
112
|
const [searchOptions, setSearchOptions] = useState<{ label: ReactNode; value: T }[]>(labeledOptions);
|
|
111
113
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
112
114
|
const optionsRef = useRef<HTMLDivElement>(null);
|
|
@@ -167,6 +169,10 @@ const DefaultSelect = <
|
|
|
167
169
|
};
|
|
168
170
|
}, [isOpen]);
|
|
169
171
|
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
setPortal(document.body);
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
170
176
|
useEffect(() => {
|
|
171
177
|
setSearchOptions(labeledOptions);
|
|
172
178
|
}, [labeledOptions]);
|
|
@@ -276,7 +282,7 @@ const DefaultSelect = <
|
|
|
276
282
|
className={cn("absolute top-1/2 right-2 -translate-y-1/2 text-lg duration-100", isOpen && "rotate-180")}
|
|
277
283
|
/>
|
|
278
284
|
</div>
|
|
279
|
-
{
|
|
285
|
+
{portal
|
|
280
286
|
? createPortal(
|
|
281
287
|
<div
|
|
282
288
|
ref={optionsRef}
|
|
@@ -348,7 +354,7 @@ const DefaultSelect = <
|
|
|
348
354
|
</div>
|
|
349
355
|
)}
|
|
350
356
|
</div>,
|
|
351
|
-
|
|
357
|
+
portal,
|
|
352
358
|
)
|
|
353
359
|
: null}
|
|
354
360
|
</div>
|