@akanjs/devkit 2.3.6-rc.0 → 2.3.6-rc.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.
@@ -10,6 +10,7 @@ import {
10
10
  shouldQueueBuildStatusReplay,
11
11
  shouldReplaceLastGoodMessage,
12
12
  shouldRestartBackendByDevPlan,
13
+ shouldRestartBuilderByDevPlan,
13
14
  shouldRestartDevHostByDevPlan,
14
15
  } from "./akanApp.host";
15
16
 
@@ -37,6 +38,10 @@ describe("shouldRestartBackendByDevPlan", () => {
37
38
  expect(shouldRestartBackendByDevPlan(invalidateWithActions(["restart-backend", "report-error"]))).toBe(false);
38
39
  });
39
40
 
41
+ test("does not use backend-only restart when builder recycle is required", () => {
42
+ expect(shouldRestartBackendByDevPlan(invalidateWithActions(["restart-backend", "restart-builder"]))).toBe(false);
43
+ });
44
+
40
45
  test("falls back when no devPlan is present", () => {
41
46
  expect(
42
47
  shouldRestartBackendByDevPlan({
@@ -49,6 +54,10 @@ describe("shouldRestartBackendByDevPlan", () => {
49
54
  });
50
55
 
51
56
  describe("shouldRestartDevHostByDevPlan", () => {
57
+ test("detects explicit restart-builder actions", () => {
58
+ expect(shouldRestartBuilderByDevPlan(invalidateWithActions(["restart-builder"]))).toBe(true);
59
+ });
60
+
52
61
  test("detects explicit restart-dev-host actions", () => {
53
62
  expect(shouldRestartDevHostByDevPlan(invalidateWithActions(["restart-dev-host"]))).toBe(true);
54
63
  });
@@ -31,9 +31,13 @@ export const shouldRestartBackendByDevPlan = (
31
31
  ): boolean | null => {
32
32
  if (!message.devPlan) return null;
33
33
  if (message.devPlan.actions.includes("report-error")) return false;
34
+ if (message.devPlan.actions.includes("restart-builder")) return false;
34
35
  return message.devPlan.actions.includes("restart-backend");
35
36
  };
36
37
 
38
+ export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
39
+ message.devPlan?.actions.includes("restart-builder") ?? false;
40
+
37
41
  export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
38
42
  message.devPlan?.actions.includes("restart-dev-host") ?? message.kinds.includes("config");
39
43
 
@@ -508,6 +512,14 @@ export class AkanAppHost {
508
512
  this.#sendToBackend(message);
509
513
  }
510
514
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
515
+ if (shouldRestartBuilderByDevPlan(message)) {
516
+ try {
517
+ await this.#restartDevChildren(message);
518
+ } catch (err) {
519
+ this.#recordDevHostRestartFailure(message, err);
520
+ }
521
+ return;
522
+ }
511
523
  if (shouldRestartDevHostByDevPlan(message)) {
512
524
  this.#recordDevHostRestartRequired(message);
513
525
  return;
@@ -518,6 +530,29 @@ export class AkanAppHost {
518
530
  }
519
531
  this.#sendToBackend(message);
520
532
  }
533
+ async #restartDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
534
+ const generation = message.devPlan?.generation ?? message.generation;
535
+ this.logger.warn(
536
+ `[dev-host] recycling builder/backend for runtime metadata generation=${generation ?? "(unknown)"} files=${message.files.length}`,
537
+ );
538
+ if (this.#restartTimer) {
539
+ clearTimeout(this.#restartTimer);
540
+ this.#restartTimer = null;
541
+ }
542
+ if (this.#backendRecoveryTimer) {
543
+ clearTimeout(this.#backendRecoveryTimer);
544
+ this.#backendRecoveryTimer = null;
545
+ }
546
+ this.#pendingRestartReason = null;
547
+ this.#lastGoodFrontend = {};
548
+ this.#buildStatusByPhase.clear();
549
+ this.#pendingBuildStatusReplay = [];
550
+ await this.#stopBackend();
551
+ this.#stopBuilder();
552
+ await this.#backendGraph.refresh();
553
+ await this.#startBuilder();
554
+ this.#startBackend({ generation, files: message.files });
555
+ }
521
556
  #recordLastGood(
522
557
  message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
523
558
  ): void {
@@ -553,6 +588,20 @@ export class AkanAppHost {
553
588
  this.#sendOrQueueBuildStatus(status);
554
589
  }
555
590
  }
591
+ #recordDevHostRestartFailure(message: Extract<BuilderMessage, { type: "invalidate" }>, err: unknown): void {
592
+ const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
593
+ const detail = err instanceof Error ? err.message : String(err);
594
+ this.logger.warn(`[dev-host] runtime metadata restart failed generation=${generation}: ${detail}`);
595
+ const status: DevBuildStatus = {
596
+ generation,
597
+ phase: "scan",
598
+ ok: false,
599
+ files: message.files,
600
+ message: `Runtime metadata change requires restarting \`akan start\` to apply: ${detail}`,
601
+ };
602
+ this.#recordBuildStatus(status);
603
+ this.#sendOrQueueBuildStatus(status);
604
+ }
556
605
  #recordBuildStatus(status: DevBuildStatus): void {
557
606
  const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
558
607
  this.#buildStatusByPhase.set(status.phase, status);
@@ -50,6 +50,7 @@ export class DevChangePlanner {
50
50
  const reasons = new Set<string>();
51
51
  const fileRoles = this.#rolesForFile(file, { isGenerated: generatedSet.has(path.resolve(file)), reasons });
52
52
  for (const role of fileRoles) roles.add(role);
53
+ if (reasons.has("runtime-metadata")) actions.add("restart-builder");
53
54
  if (reasons.size > 0) reasonByFile[path.resolve(file)] = [...reasons].sort();
54
55
  }
55
56
 
@@ -104,6 +105,9 @@ export class DevChangePlanner {
104
105
  roles.add("shared");
105
106
  reasons.add("shared-path");
106
107
  }
108
+ if (isSource && this.#isRuntimeMetadataFile(parts, base)) {
109
+ reasons.add("runtime-metadata");
110
+ }
107
111
 
108
112
  if (roles.has("server") && roles.has("client")) {
109
113
  roles.delete("server");
@@ -148,6 +152,14 @@ export class DevChangePlanner {
148
152
  );
149
153
  }
150
154
 
155
+ #isRuntimeMetadataFile(parts: string[], base: string): boolean {
156
+ const parent = parts.at(-2);
157
+ if (parent === "lib" && RUNTIME_METADATA_BASENAMES.has(base)) return true;
158
+ const libIndex = parts.lastIndexOf("lib");
159
+ if (libIndex < 0 || parts.length <= libIndex + 1) return false;
160
+ return base.endsWith(".dictionary.ts") || base.endsWith(".signal.ts");
161
+ }
162
+
151
163
  #isBarrelFacetChild(parts: string[]): boolean {
152
164
  if (parts.length < 4) return false;
153
165
  const [scope, , facet, child] = parts;
@@ -338,6 +338,27 @@ describe("DevChangePlanner", () => {
338
338
  planner.plan({ generation: 2, files: [`${root}/apps/akan/akan.config.ts`], kinds: ["config"] }).actions,
339
339
  ).toEqual(["restart-dev-host"]);
340
340
  });
341
+
342
+ test("recycles builder for macro-backed dictionary and signal metadata changes", () => {
343
+ const root = "/repo";
344
+ const planner = new DevChangePlanner({ workspaceRoot: root });
345
+ const dictionaryPlan = planner.plan({
346
+ generation: 3,
347
+ files: [`${root}/apps/demo/lib/_demo/demo.dictionary.ts`],
348
+ kinds: ["code"],
349
+ });
350
+ const signalPlan = planner.plan({
351
+ generation: 4,
352
+ files: [`${root}/libs/shared/lib/admin/admin.signal.ts`],
353
+ kinds: ["code"],
354
+ });
355
+
356
+ expect(dictionaryPlan.actions).toEqual(["rebuild-client", "restart-backend", "restart-builder"]);
357
+ expect(dictionaryPlan.reasonByFile[`${root}/apps/demo/lib/_demo/demo.dictionary.ts`]).toContain(
358
+ "runtime-metadata",
359
+ );
360
+ expect(signalPlan.actions).toContain("restart-builder");
361
+ });
341
362
  });
342
363
 
343
364
  describe("CssImportResolver", () => {
@@ -3,7 +3,7 @@ import { DevGeneratedIndexSync } from "../frontendBuild";
3
3
  import { DevStabilityHarness } from "./devStabilityHarness";
4
4
 
5
5
  const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
6
- const INTEGRATION_TIMEOUT_MS = 60_000;
6
+ const INTEGRATION_TIMEOUT_MS = 120_000;
7
7
  const harnesses: DevStabilityHarness[] = [];
8
8
 
9
9
  const integrationTest = (name: string, fn: () => Promise<void>): void => {
@@ -125,6 +125,40 @@ describe("dev stability integration harness", () => {
125
125
  hmr?.close();
126
126
  });
127
127
 
128
+ integrationTest("dictionary edits recycle runtime metadata and replace stale snapshots", async () => {
129
+ const harness = await createHarness();
130
+ const host = await harness.startHost();
131
+ const hmr = await harness.tryConnectHmrProbe();
132
+ const initialHtml = await harness.tryWaitForHttpText("initial-shared-marker", 3_000);
133
+ if (!initialHtml) {
134
+ expect(host.proc.killed).toBe(false);
135
+ hmr?.close();
136
+ return;
137
+ }
138
+ const mark = host.markLog();
139
+
140
+ await harness.writeFile(
141
+ "lib/_fixture/fixture.dictionary.ts",
142
+ `import { serviceDictionary } from "akanjs/dictionary";
143
+
144
+ import type { FixtureEndpoint } from "./fixture.signal";
145
+
146
+ export const dictionary = serviceDictionary(["en", "ko"])
147
+ .endpoint<FixtureEndpoint>(() => ({}))
148
+ .translate({
149
+ hello: ["Updated Dictionary", "업데이트 사전"],
150
+ });
151
+ `,
152
+ );
153
+
154
+ await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-builder/);
155
+ await host.waitForLogSince(mark, /\[dev-host\] recycling builder\/backend for runtime metadata/);
156
+ await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
157
+ await harness.waitForHttpText("initial-shared-marker");
158
+ expect(host.proc.killed).toBe(false);
159
+ hmr?.close();
160
+ });
161
+
128
162
  integrationTest("client build failure reports error and recovers after fix", async () => {
129
163
  const harness = await createHarness();
130
164
  const host = await harness.startHost();
@@ -26,7 +26,7 @@ export interface DevStabilityHmrProbe {
26
26
  close(): void;
27
27
  }
28
28
 
29
- const DEFAULT_TIMEOUT_MS = 20_000;
29
+ const DEFAULT_TIMEOUT_MS = 60_000;
30
30
 
31
31
  const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
32
32
 
@@ -196,6 +196,38 @@ export default function Page() {
196
196
  this.writeFile(
197
197
  "srvkit/backendMarker.ts",
198
198
  `export const backendMarker = "initial-backend-marker";
199
+ `,
200
+ ),
201
+ this.writeFile(
202
+ "lib/_fixture/fixture.service.ts",
203
+ `import { serve } from "akanjs/service";
204
+
205
+ export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
206
+ `,
207
+ ),
208
+ this.writeFile(
209
+ "lib/_fixture/fixture.signal.ts",
210
+ `import { endpoint, internal } from "akanjs/signal";
211
+
212
+ import * as srv from "../srv";
213
+
214
+ export class FixtureInternal extends internal(srv.fixture, () => ({})) {}
215
+
216
+ export class FixtureEndpoint extends endpoint(srv.fixture, () => ({})) {}
217
+ `,
218
+ ),
219
+ this.writeFile(
220
+ "lib/_fixture/fixture.dictionary.ts",
221
+ `import { serviceDictionary } from "akanjs/dictionary";
222
+
223
+ import type { FixtureEndpoint } from "./fixture.signal";
224
+
225
+ export const dictionary = serviceDictionary(["en", "ko"])
226
+ .endpoint<FixtureEndpoint>(() => ({}))
227
+ .translate({
228
+ hello: ["Initial Dictionary", "초기 사전"],
229
+ removeMe: ["Remove Me", "삭제 예정"],
230
+ });
199
231
  `,
200
232
  ),
201
233
  this.writeFile(
@@ -222,7 +254,7 @@ export default function Page() {
222
254
 
223
255
  async startHost(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHost> {
224
256
  const logs: string[] = [];
225
- const proc = Bun.spawn(["bun", "run", "akan", "start", this.appName], {
257
+ const proc = Bun.spawn(["bash", "-lc", `bun run akan start ${JSON.stringify(this.appName)}`], {
226
258
  cwd: this.workspaceRoot,
227
259
  env: {
228
260
  ...process.env,
@@ -237,7 +269,16 @@ export default function Page() {
237
269
  const consume = async (stream: ReadableStream<Uint8Array> | null) => {
238
270
  if (!stream) return;
239
271
  const decoder = new TextDecoder();
240
- for await (const chunk of stream) logs.push(decoder.decode(chunk));
272
+ const reader = stream.getReader();
273
+ try {
274
+ while (true) {
275
+ const { done, value } = await reader.read();
276
+ if (done) break;
277
+ logs.push(decoder.decode(value, { stream: true }));
278
+ }
279
+ } finally {
280
+ reader.releaseLock();
281
+ }
241
282
  };
242
283
  void consume(proc.stdout);
243
284
  void consume(proc.stderr);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.6-rc.0",
3
+ "version": "2.3.6-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.3.6-rc.0",
35
+ "akanjs": "2.3.6-rc.1",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "^5.5.20",