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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.51",
3
+ "version": "3.0.0-alpha.52",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
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
- const code = AkanApp.#sendableCloseCode(event.code);
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
- const upstreamCode = AkanApp.#sendableCloseCode(code);
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 && typeof document !== "undefined" ? createPortal(panel, document.body) : null}
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
- {typeof document !== "undefined"
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
- document.body,
357
+ portal,
352
358
  )
353
359
  : null}
354
360
  </div>