@akanjs/devkit 2.3.5 → 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.
@@ -0,0 +1,179 @@
1
+ import path from "node:path";
2
+ import type { ChangeKind, DevChangeAction, DevChangePlan, DevChangeRole } from "akanjs/server";
3
+
4
+ const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
5
+ const CONFIG_BASENAMES = new Set(["akan.config.ts", "bunfig.toml", "tsconfig.json", "package.json"]);
6
+ const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
7
+ const CLIENT_SUFFIXES = [".Template.tsx", ".Unit.tsx", ".Util.tsx", ".View.tsx", ".Zone.tsx", ".store.ts"];
8
+ const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
9
+ const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
10
+ const RUNTIME_METADATA_BASENAMES = new Set(["dict.ts", "sig.ts", "useClient.ts", "useServer.ts"]);
11
+
12
+ export interface DevChangePlannerOptions {
13
+ workspaceRoot: string;
14
+ }
15
+
16
+ export interface DevChangePlanInput {
17
+ generation: number;
18
+ files: string[];
19
+ kinds: Iterable<Exclude<ChangeKind, "ignore">>;
20
+ generatedFiles?: string[];
21
+ }
22
+
23
+ export class DevChangePlanner {
24
+ readonly #workspaceRoot: string;
25
+
26
+ constructor({ workspaceRoot }: DevChangePlannerOptions) {
27
+ this.#workspaceRoot = path.resolve(workspaceRoot);
28
+ }
29
+
30
+ plan({ generation, files, kinds, generatedFiles = [] }: DevChangePlanInput): DevChangePlan {
31
+ const fileList = uniqueResolved([...files, ...generatedFiles]);
32
+ const generatedSet = new Set(generatedFiles.map((file) => path.resolve(file)));
33
+ const kindSet = new Set(kinds);
34
+ const roles = new Set<DevChangeRole>();
35
+ const actions = new Set<DevChangeAction>();
36
+ const reasonByFile: Record<string, string[]> = {};
37
+
38
+ for (const kind of kindSet) {
39
+ if (kind === "css") {
40
+ roles.add("css");
41
+ actions.add("rebuild-css");
42
+ }
43
+ if (kind === "config") {
44
+ roles.add("config");
45
+ actions.add("restart-dev-host");
46
+ }
47
+ }
48
+
49
+ for (const file of fileList) {
50
+ const reasons = new Set<string>();
51
+ const fileRoles = this.#rolesForFile(file, { isGenerated: generatedSet.has(path.resolve(file)), reasons });
52
+ for (const role of fileRoles) roles.add(role);
53
+ if (reasons.has("runtime-metadata")) actions.add("restart-builder");
54
+ if (reasons.size > 0) reasonByFile[path.resolve(file)] = [...reasons].sort();
55
+ }
56
+
57
+ if (roles.has("barrel")) actions.add("sync-generated");
58
+ if (roles.has("server") || roles.has("shared")) actions.add("restart-backend");
59
+ if (roles.has("client") || roles.has("shared")) actions.add("rebuild-client");
60
+ if (roles.has("css")) actions.add("rebuild-css");
61
+
62
+ return {
63
+ generation,
64
+ files: fileList,
65
+ generatedFiles: uniqueResolved(generatedFiles),
66
+ roles: [...roles].sort(),
67
+ actions: [...actions].sort(),
68
+ reasonByFile,
69
+ };
70
+ }
71
+
72
+ #rolesForFile(
73
+ file: string,
74
+ { isGenerated, reasons }: { isGenerated: boolean; reasons: Set<string> },
75
+ ): Set<DevChangeRole> {
76
+ const roles = new Set<DevChangeRole>();
77
+ const abs = path.resolve(file);
78
+ const base = path.basename(abs);
79
+ const ext = path.extname(abs).toLowerCase();
80
+ const isSource = SOURCE_EXTS.has(ext);
81
+ const rel = path.relative(this.#workspaceRoot, abs);
82
+ const parts = rel.split(path.sep).filter(Boolean);
83
+
84
+ if (CONFIG_BASENAMES.has(base)) {
85
+ roles.add("config");
86
+ reasons.add("config-file");
87
+ }
88
+ if (ext === ".css") {
89
+ roles.add("css");
90
+ reasons.add("css-file");
91
+ }
92
+ if (isGenerated || (isSource && this.#isBarrelFacetChild(parts))) {
93
+ roles.add("barrel");
94
+ reasons.add(isGenerated ? "generated-index" : "barrel-facet-child");
95
+ }
96
+ if (isSource && this.#isServerBiased(abs, parts)) {
97
+ roles.add("server");
98
+ reasons.add("server-path");
99
+ }
100
+ if (isSource && this.#isClientBiased(abs, parts)) {
101
+ roles.add("client");
102
+ reasons.add("client-path");
103
+ }
104
+ if (isSource && this.#isSharedBiased(abs, parts)) {
105
+ roles.add("shared");
106
+ reasons.add("shared-path");
107
+ }
108
+ if (isSource && this.#isRuntimeMetadataFile(parts, base)) {
109
+ reasons.add("runtime-metadata");
110
+ }
111
+
112
+ if (roles.has("server") && roles.has("client")) {
113
+ roles.delete("server");
114
+ roles.delete("client");
115
+ roles.add("shared");
116
+ reasons.add("server-client-overlap");
117
+ }
118
+ if (roles.size === 0 && SOURCE_EXTS.has(ext) && this.#isWorkspaceSource(rel)) {
119
+ roles.add("shared");
120
+ reasons.add("workspace-source-fallback");
121
+ }
122
+
123
+ return roles;
124
+ }
125
+
126
+ #isServerBiased(abs: string, parts: string[]): boolean {
127
+ const base = path.basename(abs);
128
+ return (
129
+ parts.includes("srvkit") ||
130
+ SERVER_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
131
+ base === "main.ts" ||
132
+ base === "server.ts"
133
+ );
134
+ }
135
+
136
+ #isClientBiased(abs: string, parts: string[]): boolean {
137
+ const base = path.basename(abs);
138
+ return (
139
+ parts.includes("ui") ||
140
+ parts.includes("webkit") ||
141
+ parts.includes("page") ||
142
+ CLIENT_SUFFIXES.some((suffix) => base.endsWith(suffix))
143
+ );
144
+ }
145
+
146
+ #isSharedBiased(abs: string, parts: string[]): boolean {
147
+ const base = path.basename(abs);
148
+ return (
149
+ parts.includes("common") ||
150
+ SHARED_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
151
+ RUNTIME_METADATA_BASENAMES.has(base)
152
+ );
153
+ }
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
+
163
+ #isBarrelFacetChild(parts: string[]): boolean {
164
+ if (parts.length < 4) return false;
165
+ const [scope, , facet, child] = parts;
166
+ if (scope !== "apps" && scope !== "libs") return false;
167
+ if (!facet || !BARREL_FACETS.has(facet)) return false;
168
+ if (!child || child.startsWith(".") || child === "index.ts" || child === "index.tsx") return false;
169
+ return true;
170
+ }
171
+
172
+ #isWorkspaceSource(rel: string): boolean {
173
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
174
+ const [scope] = rel.split(path.sep);
175
+ return scope === "apps" || scope === "libs" || scope === "pkgs";
176
+ }
177
+ }
178
+
179
+ const uniqueResolved = (files: string[]) => [...new Set(files.map((file) => path.resolve(file)))].sort();
@@ -0,0 +1,157 @@
1
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
5
+ const FACET_SOURCE_FILE_RE = /\.(ts|tsx)$/;
6
+ const FACET_EXCLUDED_FILE_RE = /(^index\.tsx?$|\.d\.ts$|\.(test|spec)\.(ts|tsx)$|\.css$|\.scss$|\.sass$)/;
7
+ const MODULE_UI_TYPES = ["Template", "Unit", "Util", "View", "Zone"] as const;
8
+ const SERVICE_UI_TYPES = ["Util", "Zone"] as const;
9
+ const SCALAR_UI_TYPES = ["Template", "Unit"] as const;
10
+
11
+ export interface GeneratedIndexSyncResult {
12
+ changedFiles: string[];
13
+ errors: string[];
14
+ }
15
+
16
+ export interface DevGeneratedIndexSyncOptions {
17
+ workspaceRoot: string;
18
+ }
19
+
20
+ export class DevGeneratedIndexSync {
21
+ readonly #workspaceRoot: string;
22
+
23
+ constructor({ workspaceRoot }: DevGeneratedIndexSyncOptions) {
24
+ this.#workspaceRoot = path.resolve(workspaceRoot);
25
+ }
26
+
27
+ async syncForBatch(files: string[]): Promise<GeneratedIndexSyncResult> {
28
+ const indexPaths = new Set<string>();
29
+ const errors: string[] = [];
30
+
31
+ for (const file of files) {
32
+ const facetIndex = this.#facetIndexFor(file);
33
+ if (facetIndex) indexPaths.add(facetIndex);
34
+ const moduleIndex = await this.#moduleIndexForDirectoryEvent(file).catch((err) => {
35
+ errors.push(`[generated-index] module detection failed for ${file}: ${formatError(err)}`);
36
+ return null;
37
+ });
38
+ if (moduleIndex) indexPaths.add(moduleIndex);
39
+ }
40
+
41
+ const changedFiles: string[] = [];
42
+ for (const indexPath of [...indexPaths].sort()) {
43
+ try {
44
+ const changed = await this.#syncIndex(indexPath);
45
+ if (changed) changedFiles.push(indexPath);
46
+ } catch (err) {
47
+ errors.push(`[generated-index] sync failed for ${indexPath}: ${formatError(err)}`);
48
+ }
49
+ }
50
+
51
+ return { changedFiles, errors };
52
+ }
53
+
54
+ #facetIndexFor(file: string): string | null {
55
+ const abs = path.resolve(file);
56
+ const rel = path.relative(this.#workspaceRoot, abs);
57
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
58
+ const parts = rel.split(path.sep).filter(Boolean);
59
+ if (parts.length < 4) return null;
60
+ const [scope, project, facet, child] = parts;
61
+ if ((scope !== "apps" && scope !== "libs") || !project || !facet || !BARREL_FACETS.has(facet)) return null;
62
+ if (!child || child.startsWith(".") || child === "index.ts" || child === "index.tsx") return null;
63
+ return path.join(this.#workspaceRoot, scope, project, facet, "index.ts");
64
+ }
65
+
66
+ async #moduleIndexForDirectoryEvent(file: string): Promise<string | null> {
67
+ const abs = path.resolve(file);
68
+ const rel = path.relative(this.#workspaceRoot, abs);
69
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
70
+ const parts = rel.split(path.sep).filter(Boolean);
71
+ if (parts.length !== 4 && parts.length !== 5) return null;
72
+ const [scope, project, libSegment, moduleSegment, scalarSegment] = parts;
73
+ if ((scope !== "apps" && scope !== "libs") || !project || libSegment !== "lib") return null;
74
+
75
+ const isExistingDirectory = await stat(abs)
76
+ .then((s) => s.isDirectory())
77
+ .catch(() => false);
78
+ const looksLikeDeletedDirectory = !path.extname(abs);
79
+ if (!isExistingDirectory && !looksLikeDeletedDirectory) return null;
80
+
81
+ if (moduleSegment === "__scalar" && scalarSegment) {
82
+ return path.join(this.#workspaceRoot, scope, project, "lib", "__scalar", scalarSegment, "index.ts");
83
+ }
84
+ if (!moduleSegment || moduleSegment === "__scalar") return null;
85
+ return path.join(this.#workspaceRoot, scope, project, "lib", moduleSegment, "index.ts");
86
+ }
87
+
88
+ async #syncIndex(indexPath: string): Promise<boolean> {
89
+ const dir = path.dirname(indexPath);
90
+ const content = await this.#contentForIndex(indexPath);
91
+ if (content === null) {
92
+ if (!(await exists(indexPath))) return false;
93
+ await rm(indexPath, { force: true });
94
+ return true;
95
+ }
96
+
97
+ const current = await readFile(indexPath, "utf8").catch(() => null);
98
+ if (current === content) return false;
99
+ await mkdir(dir, { recursive: true });
100
+ await writeFile(indexPath, content);
101
+ return true;
102
+ }
103
+
104
+ async #contentForIndex(indexPath: string): Promise<string | null> {
105
+ const parts = path.relative(this.#workspaceRoot, indexPath).split(path.sep).filter(Boolean);
106
+ const facet = parts.at(-2);
107
+ if (facet && BARREL_FACETS.has(facet)) return this.#facetContent(path.dirname(indexPath));
108
+ return this.#moduleContent(path.dirname(indexPath));
109
+ }
110
+
111
+ async #facetContent(dir: string): Promise<string | null> {
112
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
113
+ const exportNames = entries
114
+ .flatMap((entry) => {
115
+ const name = entry.name;
116
+ if (name.startsWith(".")) return [];
117
+ if (entry.isDirectory()) return [name];
118
+ if (!entry.isFile()) return [];
119
+ if (!FACET_SOURCE_FILE_RE.test(name) || FACET_EXCLUDED_FILE_RE.test(name)) return [];
120
+ return [name.replace(FACET_SOURCE_FILE_RE, "")];
121
+ })
122
+ .sort();
123
+ if (exportNames.length === 0) return null;
124
+ return `${exportNames.map((name) => `export * from "./${name}";`).join("\n")}\n`;
125
+ }
126
+
127
+ async #moduleContent(dir: string): Promise<string | null> {
128
+ const rel = path.relative(this.#workspaceRoot, dir);
129
+ const parts = rel.split(path.sep).filter(Boolean);
130
+ const moduleSegment = parts.at(-1);
131
+ if (!moduleSegment) return null;
132
+ const isScalar = parts.at(-2) === "__scalar";
133
+ const rawModel = moduleSegment.startsWith("_") ? moduleSegment.slice(1) : moduleSegment;
134
+ if (!rawModel) return null;
135
+ const modelName = capitalize(rawModel);
136
+ const allowedTypes = isScalar
137
+ ? SCALAR_UI_TYPES
138
+ : moduleSegment.startsWith("_")
139
+ ? SERVICE_UI_TYPES
140
+ : MODULE_UI_TYPES;
141
+ const fileTypes: string[] = [];
142
+ for (const type of allowedTypes) {
143
+ if (await exists(path.join(dir, `${modelName}.${type}.tsx`))) fileTypes.push(type);
144
+ }
145
+ if (fileTypes.length === 0) return null;
146
+ return `\n${fileTypes.map((type) => `import * as ${type} from "./${modelName}.${type}";`).join("\n")}\n\nexport const ${modelName} = { ${fileTypes.join(", ")} };`;
147
+ }
148
+ }
149
+
150
+ const exists = async (file: string) =>
151
+ stat(file)
152
+ .then(() => true)
153
+ .catch(() => false);
154
+
155
+ const capitalize = (value: string) => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
156
+
157
+ const formatError = (err: unknown) => (err instanceof Error ? err.message : String(err));
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -7,6 +7,8 @@ import type { RoutesManifest } from "akanjs/server";
7
7
  import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
8
  import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
9
9
  import { CssImportResolver } from "./cssImportResolver";
10
+ import { DevChangePlanner } from "./devChangePlanner";
11
+ import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
10
12
  import { HmrChangeClassifier } from "./hmrChangeClassifier";
11
13
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
12
14
  import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
@@ -252,6 +254,113 @@ describe("HmrChangeClassifier", () => {
252
254
  });
253
255
  });
254
256
 
257
+ describe("DevGeneratedIndexSync", () => {
258
+ test("updates barrel facet index for file add and delete", async () => {
259
+ const root = await makeTempRoot();
260
+ const foo = path.join(root, "libs/shared/common/foo.ts");
261
+ const index = path.join(root, "libs/shared/common/index.ts");
262
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
263
+
264
+ await write(foo, "export const foo = 1;\n");
265
+ const added = await sync.syncForBatch([foo]);
266
+
267
+ expect(added.errors).toEqual([]);
268
+ expect(added.changedFiles).toEqual([index]);
269
+ expect(await readFile(index, "utf8")).toBe('export * from "./foo";\n');
270
+
271
+ await rm(foo);
272
+ const removed = await sync.syncForBatch([foo]);
273
+
274
+ expect(removed.errors).toEqual([]);
275
+ expect(removed.changedFiles).toEqual([index]);
276
+ expect(await Bun.file(index).exists()).toBe(false);
277
+ });
278
+
279
+ test("ignores server/client folders as barrel facets", async () => {
280
+ const root = await makeTempRoot();
281
+ const serverFile = path.join(root, "libs/shared/server/foo.ts");
282
+ const clientFile = path.join(root, "libs/shared/client/foo.ts");
283
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
284
+
285
+ await write(serverFile, "export const foo = 1;\n");
286
+ await write(clientFile, "export const foo = 1;\n");
287
+ const result = await sync.syncForBatch([serverFile, clientFile]);
288
+
289
+ expect(result.errors).toEqual([]);
290
+ expect(result.changedFiles).toEqual([]);
291
+ });
292
+
293
+ test("ignores individual module UI file changes for module index sync", async () => {
294
+ const root = await makeTempRoot();
295
+ const template = path.join(root, "libs/shared/lib/admin/Admin.Template.tsx");
296
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
297
+
298
+ await write(template, "export const Admin = () => null;\n");
299
+ const result = await sync.syncForBatch([template]);
300
+
301
+ expect(result.errors).toEqual([]);
302
+ expect(result.changedFiles).toEqual([]);
303
+ });
304
+ });
305
+
306
+ describe("DevChangePlanner", () => {
307
+ test("classifies server, client, shared, and generated barrel changes", () => {
308
+ const root = "/repo";
309
+ const planner = new DevChangePlanner({ workspaceRoot: root });
310
+ const generatedIndex = `${root}/libs/shared/common/index.ts`;
311
+ const plan = planner.plan({
312
+ generation: 7,
313
+ files: [
314
+ `${root}/libs/shared/lib/admin/admin.service.ts`,
315
+ `${root}/libs/shared/lib/admin/Admin.Template.tsx`,
316
+ `${root}/libs/shared/lib/admin/admin.constant.ts`,
317
+ `${root}/libs/shared/common/foo.ts`,
318
+ ],
319
+ kinds: ["code"],
320
+ generatedFiles: [generatedIndex],
321
+ });
322
+
323
+ expect(plan.generatedFiles).toEqual([generatedIndex]);
324
+ expect(plan.files).toContain(generatedIndex);
325
+ expect(plan.roles).toEqual(["barrel", "client", "server", "shared"]);
326
+ expect(plan.actions).toEqual(["rebuild-client", "restart-backend", "sync-generated"]);
327
+ expect(plan.reasonByFile[generatedIndex]).toContain("generated-index");
328
+ });
329
+
330
+ test("keeps css-only and config changes separate from backend restarts", () => {
331
+ const root = "/repo";
332
+ const planner = new DevChangePlanner({ workspaceRoot: root });
333
+
334
+ expect(
335
+ planner.plan({ generation: 1, files: [`${root}/apps/akan/page/style.css`], kinds: ["css"] }).actions,
336
+ ).toEqual(["rebuild-css"]);
337
+ expect(
338
+ planner.plan({ generation: 2, files: [`${root}/apps/akan/akan.config.ts`], kinds: ["config"] }).actions,
339
+ ).toEqual(["restart-dev-host"]);
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
+ });
362
+ });
363
+
255
364
  describe("CssImportResolver", () => {
256
365
  test("identifies package names and css files", () => {
257
366
  expect(CssImportResolver.getPackageName("@scope/pkg/button")).toBe("@scope/pkg");
@@ -5,6 +5,8 @@ export * from "./clientEntryDiscovery";
5
5
  export * from "./csrArtifactBuilder";
6
6
  export * from "./cssCompiler";
7
7
  export * from "./cssImportResolver";
8
+ export * from "./devChangePlanner";
9
+ export * from "./devGeneratedIndexSync";
8
10
  export * from "./fontOptimizer";
9
11
  export * from "./hmrChangeClassifier";
10
12
  export * from "./hmrWatcher";
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DevChangePlanner } from "../frontendBuild";
3
+ import { prepareDevWatchBatch } from "./devWatchBatch";
4
+
5
+ describe("prepareDevWatchBatch", () => {
6
+ test("includes generated indexes in the same invalidate generation", () => {
7
+ const root = "/repo";
8
+ const changedFile = `${root}/libs/shared/common/foo.ts`;
9
+ const generatedIndex = `${root}/libs/shared/common/index.ts`;
10
+ const prepared = prepareDevWatchBatch({
11
+ generation: 12,
12
+ batch: { files: [changedFile], kinds: new Set(["code"]) },
13
+ indexSync: { changedFiles: [generatedIndex], errors: [] },
14
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
15
+ });
16
+
17
+ expect(prepared.hasSyncErrors).toBe(false);
18
+ expect(prepared.files).toEqual([changedFile, generatedIndex]);
19
+ expect(prepared.event.generation).toBe(12);
20
+ expect(prepared.event.files).toEqual(prepared.files);
21
+ expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
22
+ expect(prepared.event.devPlan?.files).toEqual(prepared.files);
23
+ });
24
+
25
+ test.each([
26
+ "common",
27
+ "srvkit",
28
+ "ui",
29
+ "webkit",
30
+ ])("keeps %s facet add/delete generated index in the same generation", (facet) => {
31
+ const root = "/repo";
32
+ const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
33
+ const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
34
+ const prepared = prepareDevWatchBatch({
35
+ generation: 20,
36
+ batch: { files: [changedFile], kinds: new Set(["code"]) },
37
+ indexSync: { changedFiles: [generatedIndex], errors: [] },
38
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
39
+ });
40
+
41
+ expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
42
+ expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
43
+ expect(prepared.event.devPlan?.roles).toContain("barrel");
44
+ expect(prepared.event.devPlan?.actions).toContain("sync-generated");
45
+ });
46
+
47
+ test("marks failed generated index sync as an error generation", () => {
48
+ const root = "/repo";
49
+ const prepared = prepareDevWatchBatch({
50
+ generation: 13,
51
+ batch: { files: [`${root}/libs/shared/common/foo.ts`], kinds: new Set(["code"]) },
52
+ indexSync: { changedFiles: [], errors: ["sync failed"] },
53
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
54
+ });
55
+
56
+ expect(prepared.hasSyncErrors).toBe(true);
57
+ expect(prepared.event.devPlan?.actions).toContain("report-error");
58
+ });
59
+ });
@@ -0,0 +1,48 @@
1
+ import type { BuilderEvent, ChangeBatch } from "akanjs/server";
2
+ import type { DevChangePlanner, GeneratedIndexSyncResult } from "../frontendBuild";
3
+
4
+ export interface PrepareDevWatchBatchOptions {
5
+ generation: number;
6
+ batch: ChangeBatch;
7
+ indexSync: GeneratedIndexSyncResult;
8
+ changePlanner: DevChangePlanner;
9
+ }
10
+
11
+ export interface PreparedDevWatchBatch {
12
+ files: string[];
13
+ kinds: ("code" | "css" | "config")[];
14
+ expandedBatch: ChangeBatch;
15
+ event: Extract<BuilderEvent, { type: "invalidate" }>;
16
+ hasSyncErrors: boolean;
17
+ }
18
+
19
+ export const prepareDevWatchBatch = ({
20
+ generation,
21
+ batch,
22
+ indexSync,
23
+ changePlanner,
24
+ }: PrepareDevWatchBatchOptions): PreparedDevWatchBatch => {
25
+ const files = [...new Set([...batch.files, ...indexSync.changedFiles])].sort();
26
+ const kindSet = new Set(batch.kinds);
27
+ if (indexSync.changedFiles.length > 0) kindSet.add("code");
28
+ const kinds = [...kindSet] as ("code" | "css" | "config")[];
29
+ const expandedBatch: ChangeBatch = { files, kinds: kindSet };
30
+ const devPlan = changePlanner.plan({
31
+ generation,
32
+ files,
33
+ kinds,
34
+ generatedFiles: indexSync.changedFiles,
35
+ });
36
+
37
+ if (indexSync.errors.length > 0 && !devPlan.actions.includes("report-error")) {
38
+ devPlan.actions = [...devPlan.actions, "report-error"].sort();
39
+ }
40
+
41
+ return {
42
+ files,
43
+ kinds,
44
+ expandedBatch,
45
+ event: { type: "invalidate", kinds, files, generation, devPlan },
46
+ hasSyncErrors: indexSync.errors.length > 0,
47
+ };
48
+ };
@@ -9,6 +9,7 @@ const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
9
9
  "invalidate",
10
10
  "css-updated",
11
11
  "pages-updated",
12
+ "build-status",
12
13
  ]);
13
14
  interface IncrementalBuilderHostOptions {
14
15
  app: App;