@akanjs/devkit 3.0.0 → 3.0.1

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.
@@ -69,7 +69,8 @@ describe("resolveSsrPageEntries", () => {
69
69
 
70
70
  const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
71
71
  expect(generatedSource).toContain("<System.Provider");
72
- expect(generatedSource).toContain("theme={userLayout.theme ?? inheritedLayout.theme}");
72
+ expect(generatedSource).toContain("export const theme = userLayout.theme ?? inheritedLayout.theme;");
73
+ expect(generatedSource).toContain("theme={theme}");
73
74
  expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
74
75
  expect(generatedSource).toContain(
75
76
  'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
@@ -215,6 +215,7 @@ const defaultFonts = userFonts.filter((font) => font.default);
215
215
  if (defaultFonts.length > 1) throw new Error("[route-convention] only one default font is allowed per root layout");
216
216
  const defaultFont = defaultFonts[0];
217
217
  const defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${defaultFont.name}\`) : undefined;
218
+ export const theme = userLayout.theme ?? inheritedLayout.theme;
218
219
 
219
220
  ${layoutExports}(
220
221
  <System.Provider
@@ -223,7 +224,7 @@ ${layoutExports}(
223
224
  ${prefix ? `prefix=${JSON.stringify(prefix)}\n ` : ""}params={params}
224
225
  manifest={userLayout.manifest ?? inheritedLayout.manifest}
225
226
  env={env}
226
- theme={userLayout.theme ?? inheritedLayout.theme}
227
+ theme={theme}
227
228
  fonts={loadFonts(userFonts)}
228
229
  className={defaultFontClassName}
229
230
  layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
@@ -33,20 +33,6 @@ describe("computeRouteSeedIndex", () => {
33
33
  ).toThrow("route conflict");
34
34
  });
35
35
 
36
- test("keeps special route seeds outside implicit locale", () => {
37
- const index = computeRouteSeedIndex([
38
- {
39
- key: "./__root_layout.tsx",
40
- moduleAbsPath: "/app/.akan/generated/root-layouts/__root_layout.tsx",
41
- seedAbsPaths: ["/app/page/_layout.tsx"],
42
- },
43
- { key: "./robots.txt.tsx", moduleAbsPath: "/app/page/robots.txt.tsx" },
44
- ]);
45
- const robots = index.entries.find((entry) => entry.routeId === "/robots.txt");
46
- expect(robots?.seeds).not.toContain(path.resolve("/app/.akan/generated/root-layouts/__root_layout.tsx"));
47
- expect(robots?.seeds).not.toContain(path.resolve("/app/page/_layout.tsx"));
48
- });
49
-
50
36
  test("serializes seed paths relative to the artifact directory", () => {
51
37
  const artifactDir = "/repo/dist/apps/akan/.akan/artifact";
52
38
  const serialized = serializeRouteSeedIndexForArtifact(
package/biome.base.json CHANGED
@@ -176,6 +176,10 @@
176
176
  "includes": ["{apps,libs}/**/*.store.ts"],
177
177
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-return-in-store-action.grit"]
178
178
  },
179
+ {
180
+ "includes": ["{apps,libs}/**/*.constant.ts"],
181
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-static-in-object-light-model.grit"]
182
+ },
179
183
  {
180
184
  "includes": ["{apps,libs}/**/*.ts", "{apps,libs}/**/*.tsx"],
181
185
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-deprecated-log-level.grit"]
@@ -203,9 +203,10 @@ export class CsrE2eHarness {
203
203
  await Bun.sleep(50);
204
204
  }
205
205
 
206
- //? The CSR frame's own dev sync-navigation event: it calls the app router's push/replace, as a click would.
206
+ //? The CSR frame's own dev sync-navigation event: it calls the app router's push/replace, as a click would, and a
207
+ //? `back` onto the entry under the page goes back, as another screen's relayed back does.
207
208
  //? The frame drops a navigation that starts while a transition runs, so each one waits the transition out.
208
- async navigate(href: string, kind: "push" | "replace" = "push", { settleMs = SETTLE_MS } = {}) {
209
+ async navigate(href: string, kind: "push" | "replace" | "back" = "push", { settleMs = SETTLE_MS } = {}) {
209
210
  const target = new URL(href, this.origin);
210
211
  await this.evaluate(
211
212
  (next: string, how: string) => {
@@ -63,6 +63,19 @@ describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)",
63
63
  expect(await csr.reloaded()).toBe(false);
64
64
  }, 90_000);
65
65
 
66
+ test("a back relayed from another screen pops onto the entry under the page, keeping its state", async () => {
67
+ await csr.open(TAB_A);
68
+ await csr.navigate(item("31"));
69
+ await csr.type(currentInput, "thirty-one");
70
+ await csr.navigate(item("32"));
71
+ await csr.navigate(item("31"), "back");
72
+ expect(await itemIdsIn(CURRENT)).toEqual(["31"]);
73
+ expect(await csr.values(currentInput)).toEqual(["thirty-one"]);
74
+ await csr.back();
75
+ expect((await csr.currentPath()).split("?")[0]).toEndWith(TAB_A);
76
+ expect(await csr.reloaded()).toBe(false);
77
+ }, 60_000);
78
+
66
79
  test("a replace within one route updates the page in place", async () => {
67
80
  await csr.open(item("21"));
68
81
  await csr.type(currentInput, "kept");
@@ -61,6 +61,12 @@ export class HmrWatcher {
61
61
  const abs = path.resolve(root, filename.toString());
62
62
  this.#queue(abs);
63
63
  });
64
+ //? Linux emits an unreadable subdirectory as an async EPERM error event, which throws when unhandled.
65
+ //? The coverage-gap scan already warns about it, so only other errors are worth a warning here.
66
+ w.on("error", (err: NodeJS.ErrnoException) => {
67
+ if (err.code === "EACCES" || err.code === "EPERM") this.#logger.verbose(`[hmr] ${err.message}`);
68
+ else this.#logger.warn(`[hmr] watcher error under ${root}: ${err.message}`);
69
+ });
64
70
  this.#watchers.push(w);
65
71
  this.#logger.verbose(`[hmr] watching ${root}`);
66
72
  } catch (err) {
@@ -583,10 +583,13 @@ describe("dev resource budgets", () => {
583
583
  expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
584
584
  });
585
585
 
586
+ //? Between a fresh builder and one that has built a route, or the host stops enforcing it or never recycles.
587
+ //? macOS keeps the bundler arenas (~380MiB built); Linux measured 101-115MiB fresh, 157-176MiB built and settled.
588
+ const CEILING_MB = process.platform === "linux" ? 135 : 200;
589
+
586
590
  budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
587
591
  const harness = await createHarness();
588
- // Below the builder once it has served the first route build (about 380MiB), which arms the recycle.
589
- const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
592
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: String(CEILING_MB) } });
590
593
  const start = host.markLog();
591
594
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
592
595
 
@@ -598,7 +601,7 @@ describe("dev resource budgets", () => {
598
601
 
599
602
  const recycleLog = await host.waitForLogSince(
600
603
  start,
601
- /recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
604
+ new RegExp(`recycling builder pid=(\\d+) \\((rss=\\d+MiB>=${CEILING_MB}MiB after \\d+ build\\(s\\))[;)]`),
602
605
  WAIT_MS,
603
606
  );
604
607
  await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
@@ -634,7 +637,7 @@ describe("dev resource budgets", () => {
634
637
  const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
635
638
  const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
636
639
  //? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
637
- if ((settled?.rssBytes ?? 0) >= 200 * MB && !warned)
640
+ if ((settled?.rssBytes ?? 0) >= CEILING_MB * MB && !warned)
638
641
  await host.waitForLogSince(
639
642
  settledFrom,
640
643
  /ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
@@ -645,7 +648,7 @@ describe("dev resource budgets", () => {
645
648
 
646
649
  budgetTest("serves a page requested while the builder is being replaced", async () => {
647
650
  const harness = await createHarness();
648
- const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
651
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: String(CEILING_MB) } });
649
652
  const port = await harness.resolvePort();
650
653
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
651
654
 
@@ -269,6 +269,8 @@ export const dictionary = serviceDictionary(["en", "ko"])
269
269
  // The dev-plan/hmr assertions match verbose-level lines.
270
270
  AKAN_PUBLIC_LOG_LEVEL: "verbose",
271
271
  NODE_NO_WARNINGS: "1",
272
+ //? CI's `.env` names the branch; any env but local makes `akan start` ssh-tunnel to that cluster's Redis.
273
+ AKAN_PUBLIC_ENV: "local",
272
274
  PORT_OFFSET: String(offset),
273
275
  // Pinned: `getDevPort()` derives from the fixture's index in `apps/`, which parallel runs keep moving.
274
276
  AKAN_DEV_PORT: String(port),
@@ -0,0 +1,43 @@
1
+ import { enumOf, ID, Int } from "akanjs/base";
2
+ import { via } from "akanjs/constant";
3
+
4
+ export class QuizInput extends via((field) => ({
5
+ userId: field(ID, { ref: "user", immutable: true }),
6
+ })) {}
7
+
8
+ export class QuizObject extends via(QuizInput, (field) => ({
9
+ correctNum: field(Int, { default: 0, min: 0 }),
10
+ })) {
11
+ static maxNum = 4; // @flag
12
+ static readonly minNum = 1; // @flag
13
+ private static secretNum = 2; // @flag
14
+ static { // @flag
15
+ QuizObject.maxNum = 5;
16
+ }
17
+ }
18
+
19
+ export class LightQuiz extends via(
20
+ QuizObject,
21
+ ["userId", "correctNum"] as const,
22
+ (resolve) => ({}),
23
+ ) {
24
+ static choiceNum = 4; // @flag
25
+ static xpOfCorrect(comboNum: number) { // @flag
26
+ return comboNum * 10;
27
+ }
28
+ static async loadAll() { // @flag
29
+ return [];
30
+ }
31
+ static get defaultNum() { // @flag
32
+ return 1;
33
+ }
34
+ isPerfect() {
35
+ return this.correctNum === 4;
36
+ }
37
+ }
38
+
39
+ export class LightRenamed extends via(QuizObject, ["userId"] as const, (resolve) => ({})) {
40
+ static sizeNum = 1; // @flag
41
+ }
42
+
43
+ export class Quiz extends via(QuizObject, LightQuiz, (resolve) => ({})) {}
@@ -0,0 +1 @@
1
+ { "path": "lib/quiz/quiz.constant.ts" }
@@ -0,0 +1,42 @@
1
+ import { ID, Int } from "akanjs/base";
2
+ import { via } from "akanjs/constant";
3
+
4
+ export class QuizInput extends via((field) => ({
5
+ userId: field(ID, { ref: "user", immutable: true }),
6
+ })) {
7
+ static minSize = 4; // @ok
8
+ }
9
+
10
+ export class QuizObject extends via(QuizInput, (field) => ({
11
+ correctNum: field(Int, { default: 0, min: 0 }),
12
+ })) {}
13
+
14
+ export class LightQuiz extends via(QuizObject, ["userId", "correctNum"] as const, (resolve) => ({})) {
15
+ isPerfect() { // @ok
16
+ return this.correctNum === 4;
17
+ }
18
+ staticNum = 1; // @ok
19
+ }
20
+
21
+ export class Quiz extends via(QuizObject, LightQuiz, (resolve) => ({})) {
22
+ static choiceNum = 4; // @ok
23
+ static xpOfCorrect(comboNum: number) { // @ok
24
+ return comboNum * 10;
25
+ }
26
+ }
27
+
28
+ export class QuizInsight extends via(Quiz, (field) => ({})) {
29
+ static emptyNum = 0; // @ok
30
+ }
31
+
32
+ export class Coordinate extends via((field) => ({
33
+ lat: field(Int),
34
+ })) {
35
+ static getDistanceKm(a: Coordinate, b: Coordinate) { // @ok
36
+ return a.lat - b.lat;
37
+ }
38
+ }
39
+
40
+ export class ObjectRegistry {
41
+ static instance = new ObjectRegistry(); // @ok
42
+ }
@@ -0,0 +1,27 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // `via(XObject, LightX, ...)` mixes in only the prototypes of XObject and LightX (applyMixins in
5
+ // akanjs/common), so a static declared on either never reaches the full model `X`. Statics belong on
6
+ // XInput (or a scalar), X, or XInsight. The Object class is recognised by its `*Object` name, because its
7
+ // via() shape is identical to the Insight's; the Light class also by its `[...] as const` field tuple.
8
+ or {
9
+ JsMethodClassMember(),
10
+ JsPropertyClassMember(),
11
+ JsGetterClassMember(),
12
+ JsSetterClassMember(),
13
+ JsStaticInitializationBlockClassMember()
14
+ } as $member where {
15
+ $member <: r"(?:(?:public|protected|private|declare|override|readonly|accessor)\s+)*static\b[\s\S]*",
16
+ $member <: within JsClassDeclaration() as $model where {
17
+ $model <: or {
18
+ r"class\s+(?:Light[A-Za-z0-9_$]*|[A-Za-z0-9_$]*Object)\s+extends\s+via\([\s\S]*",
19
+ r"class\s+[A-Za-z0-9_$]+\s+extends\s+via\(\s*[A-Za-z0-9_$.]+\s*,\s*\[[\s\S]*"
20
+ }
21
+ },
22
+ register_diagnostic(
23
+ span = $member,
24
+ message = "A static on an XObject or LightX class never reaches the full model, because via() mixes in their prototypes only. Declare it on XInput, the full model X, or XInsight instead.",
25
+ severity = "error"
26
+ )
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,7 +47,7 @@
47
47
  "@earendil-works/pi-tui": "0.86.1",
48
48
  "@inquirer/prompts": "^8.4.3",
49
49
  "@tailwindcss/node": "^4.3.0",
50
- "akanjs": "3.0.0",
50
+ "akanjs": "3.0.1",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",
package/subspace.test.ts CHANGED
@@ -10,7 +10,16 @@ import { isolateEnv, tempDirs, writeText as write } from "./testHelpers";
10
10
  //? Each case drives several real git clones and pushes; on a 4-core Windows VM that alone lands at 2-5s.
11
11
  setDefaultTimeout(20_000);
12
12
 
13
- isolateEnv({ AKAN_PUBLIC_REPO_NAME: "workspace", AKAN_PUBLIC_SERVE_DOMAIN: "example.com", AKAN_PUBLIC_ENV: "local" });
13
+ //? Subspace commits in its clone with the host's identity, and a CI host has none configured.
14
+ isolateEnv({
15
+ AKAN_PUBLIC_REPO_NAME: "workspace",
16
+ AKAN_PUBLIC_SERVE_DOMAIN: "example.com",
17
+ AKAN_PUBLIC_ENV: "local",
18
+ GIT_AUTHOR_NAME: "t",
19
+ GIT_AUTHOR_EMAIL: "t@t",
20
+ GIT_COMMITTER_NAME: "t",
21
+ GIT_COMMITTER_EMAIL: "t@t",
22
+ });
14
23
  const makeTempRoot = tempDirs("akan-subspace-");
15
24
 
16
25
  const git = async (cwd: string, args: string[]) =>