@akanjs/devkit 3.0.0-alpha.96 → 3.0.0-alpha.97

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/codegenLock.ts CHANGED
@@ -11,10 +11,10 @@ interface LockHolder {
11
11
  /**
12
12
  * A workspace-wide mutex over the generated source files every dev server in the workspace rewrites.
13
13
  *
14
- * `WatchRootResolver` narrows the `apps/` container to one app but keeps `libs/` whole on purpose, so
15
- * with two dev servers up a save under `libs/` reaches both builders and both regenerate the same
16
- * barrel. Whichever watcher is mid-scan then reads a half-written file back as a user edit, which is a
17
- * rebuild per rewrite. `scanSync` writes the same files at boot for every mounting app.
14
+ * `WatchRootResolver` narrows each dev server to its own app and its own lib dependencies, but two apps
15
+ * that share a lib still both watch it, so a save there reaches both builders and both regenerate the
16
+ * same barrel. Whichever watcher is mid-scan then reads a half-written file back as a user edit, which is
17
+ * a rebuild per rewrite. `scanSync` writes the same files at boot for every mounting app.
18
18
  *
19
19
  * A wait that expires proceeds *without* the lock rather than failing: this sits on the dev server's
20
20
  * hot path, and stalling the file watcher is worse than the torn read `FileSys.writeTextAtomic` already
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import type { App } from "../commandDecorators";
@@ -14,11 +14,23 @@ const makeWorkspace = async (dirs: string[]) => {
14
14
  return root;
15
15
  };
16
16
 
17
- const makeApp = (workspaceRoot: string, name: string, paths: Record<string, string[]>) =>
17
+ const writeManifest = (workspaceRoot: string, scope: string, libDeps: unknown) =>
18
+ writeFile(
19
+ path.join(workspaceRoot, scope, scope.startsWith("apps/") ? "akan.app.json" : "akan.lib.json"),
20
+ JSON.stringify({ libDeps }),
21
+ );
22
+
23
+ interface AppStub {
24
+ paths?: Record<string, string[]>;
25
+ libDeps?: string[] | null;
26
+ }
27
+
28
+ const makeApp = (workspaceRoot: string, name: string, { paths = {}, libDeps = null }: AppStub = {}) =>
18
29
  ({
19
30
  cwdPath: path.join(workspaceRoot, "apps", name),
20
31
  workspace: { workspaceRoot },
21
32
  getTsConfig: async () => ({ compilerOptions: { paths } }),
33
+ getScanInfo: () => (libDeps ? { type: "app", libDeps } : null),
22
34
  }) as unknown as App;
23
35
 
24
36
  afterEach(async () => {
@@ -29,7 +41,7 @@ describe("WatchRootResolver", () => {
29
41
  test("narrows the apps container to the app being served", async () => {
30
42
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "apps/app2/page", "libs/util"]);
31
43
  const resolved = await new WatchRootResolver(
32
- makeApp(workspaceRoot, "app1", { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] }),
44
+ makeApp(workspaceRoot, "app1", { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } }),
33
45
  ).resolve();
34
46
 
35
47
  expect(resolved).toContain(path.join(workspaceRoot, "apps/app1"));
@@ -37,9 +49,57 @@ describe("WatchRootResolver", () => {
37
49
  expect(resolved).not.toContain(path.join(workspaceRoot, "apps/app2"));
38
50
  });
39
51
 
40
- test("keeps the libs container whole", async () => {
52
+ test("narrows the libs container to the app's own dependencies", async () => {
53
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
54
+ const resolved = await new WatchRootResolver(
55
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] }, libDeps: ["util"] }),
56
+ ).resolve();
57
+
58
+ expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
59
+ expect(resolved).not.toContain(path.join(workspaceRoot, "libs"));
60
+ expect(resolved).not.toContain(path.join(workspaceRoot, "libs/shared"));
61
+ });
62
+
63
+ test("drops the libs container entirely when the app depends on no lib", async () => {
64
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util"]);
65
+ await writeManifest(workspaceRoot, "apps/app1", []);
66
+ const resolved = await new WatchRootResolver(
67
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
68
+ ).resolve();
69
+
70
+ expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
71
+ });
72
+
73
+ test("takes the transitive closure from the synced manifests when nothing scanned in-process", async () => {
74
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared", "libs/unused"]);
75
+ await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
76
+ await writeManifest(workspaceRoot, "libs/shared", ["util"]);
77
+ await writeManifest(workspaceRoot, "libs/util", []);
78
+ await writeManifest(workspaceRoot, "libs/unused", []);
79
+ const resolved = await new WatchRootResolver(
80
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
81
+ ).resolve();
82
+
83
+ expect(resolved).toContain(path.join(workspaceRoot, "libs/shared"));
84
+ expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
85
+ expect(resolved).not.toContain(path.join(workspaceRoot, "libs/unused"));
86
+ });
87
+
88
+ test("keeps the libs container whole when the app manifest is missing", async () => {
89
+ const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
90
+ const resolved = await new WatchRootResolver(
91
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
92
+ ).resolve();
93
+
94
+ expect(resolved).toContain(path.join(workspaceRoot, "libs"));
95
+ });
96
+
97
+ test("keeps the libs container whole when a dependency lib is unsynced", async () => {
41
98
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
42
- const resolved = await new WatchRootResolver(makeApp(workspaceRoot, "app1", { "@libs/*": ["./libs/*"] })).resolve();
99
+ await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
100
+ const resolved = await new WatchRootResolver(
101
+ makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
102
+ ).resolve();
43
103
 
44
104
  expect(resolved).toContain(path.join(workspaceRoot, "libs"));
45
105
  });
@@ -48,9 +108,11 @@ describe("WatchRootResolver", () => {
48
108
  const workspaceRoot = await makeWorkspace(["apps/app1/page", "pkgs/akanjs/base"]);
49
109
  const resolved = await new WatchRootResolver(
50
110
  makeApp(workspaceRoot, "app1", {
51
- akanjs: ["./pkgs/akanjs/index.ts"],
52
- "akanjs/*": ["./pkgs/akanjs/*"],
53
- missing: ["./pkgs/nothing/index.ts"],
111
+ paths: {
112
+ akanjs: ["./pkgs/akanjs/index.ts"],
113
+ "akanjs/*": ["./pkgs/akanjs/*"],
114
+ missing: ["./pkgs/nothing/index.ts"],
115
+ },
54
116
  }),
55
117
  ).resolve();
56
118
 
@@ -60,7 +122,7 @@ describe("WatchRootResolver", () => {
60
122
 
61
123
  test("always watches the app's page tree", async () => {
62
124
  const workspaceRoot = await makeWorkspace(["apps/app1/page"]);
63
- const resolved = await new WatchRootResolver(makeApp(workspaceRoot, "app1", {})).resolve();
125
+ const resolved = await new WatchRootResolver(makeApp(workspaceRoot, "app1")).resolve();
64
126
 
65
127
  expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
66
128
  });
@@ -1,15 +1,24 @@
1
1
  import fs from "node:fs";
2
+ import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import type { App } from "../commandDecorators";
4
5
 
5
6
  /**
6
7
  * The directories one app's dev watcher follows, resolved from its tsconfig `paths`.
7
8
  *
8
- * `@apps/*` strips down to the `apps/` container, so taking it verbatim puts every sibling app under the
9
- * watcher: with two dev servers up, a save in app2 rebuilds, restarts and reloads app1, and both builders
10
- * rewrite the same generated barrels. Apps are leaves of the workspace graph — never one another's
11
- * dependencies — so the container is replaced by this app's own directory. `libs/` stays whole, because a
12
- * lib becomes a dependency the moment someone types the import, with no restart in between.
9
+ * Two aliases strip down to a workspace container rather than a package, and taking either verbatim puts
10
+ * code the app never imports under the watcher — where a save rebuilds, restarts and reloads it for
11
+ * nothing, and both builders rewrite the same generated barrels:
12
+ *
13
+ * - `@apps/*` becomes the `apps/` container. Apps are leaves of the workspace graph never one another's
14
+ * dependencies — so it is replaced by this app's own directory.
15
+ * - `@libs/*` becomes the `libs/` container. A lib is a dependency only if the app reaches it, so it is
16
+ * replaced by the app's own lib dependencies, transitively. The set is resolved once, at watcher
17
+ * install: a lib that becomes a dependency mid-session needs a fresh `akan start` anyway, because the
18
+ * import that made it one also needs a `sync`.
19
+ *
20
+ * Failing to resolve that set keeps the container whole, because a wrong narrowing is a dev server that
21
+ * silently ignores edits.
13
22
  */
14
23
  export class WatchRootResolver {
15
24
  #app: App;
@@ -22,6 +31,8 @@ export class WatchRootResolver {
22
31
  const tsconfig = await this.#app.getTsConfig();
23
32
  const appDir = path.resolve(this.#app.cwdPath);
24
33
  const appsContainer = path.dirname(appDir);
34
+ const libsContainer = path.resolve(this.#app.workspace.workspaceRoot, "libs");
35
+ const libRoots = await this.#resolveLibRoots(libsContainer);
25
36
  const set = new Set<string>();
26
37
  set.add(path.resolve(`${this.#app.cwdPath}/page`));
27
38
  for (const targets of Object.values(tsconfig.compilerOptions.paths ?? {})) {
@@ -31,10 +42,52 @@ export class WatchRootResolver {
31
42
  // Strip the trailing filename and glob so we watch the package root dir.
32
43
  const cleaned = target.replace(/\/?\*+.*$/, "").replace(/\/[^/]+\.[^/]+$/, "");
33
44
  const resolved = path.resolve(this.#app.workspace.workspaceRoot, cleaned);
45
+ if (resolved === libsContainer && libRoots) {
46
+ for (const root of libRoots) set.add(root);
47
+ continue;
48
+ }
34
49
  const root = resolved === appsContainer ? appDir : resolved;
35
50
  if (fs.existsSync(root)) set.add(root);
36
51
  }
37
52
  }
38
53
  return [...set];
39
54
  }
55
+
56
+ async #resolveLibRoots(libsContainer: string): Promise<string[] | null> {
57
+ const libDeps = await this.#resolveLibDeps(libsContainer);
58
+ if (!libDeps) return null;
59
+ return libDeps.map((name) => path.join(libsContainer, name)).filter((dir) => fs.existsSync(dir));
60
+ }
61
+
62
+ async #resolveLibDeps(libsContainer: string): Promise<string[] | null> {
63
+ const scanInfo = this.#app.getScanInfo({ allowEmpty: true });
64
+ // Already transitive, and present whenever this runs in a process that scanned. The builder and the
65
+ // idle watcher run in processes that did not, so they take the manifests `scan` wrote instead.
66
+ if (scanInfo?.type === "app") return scanInfo.libDeps;
67
+ const direct = await WatchRootResolver.#readManifestLibDeps(path.join(this.#app.cwdPath, "akan.app.json"));
68
+ if (!direct) return null;
69
+ const closure = new Set<string>();
70
+ const queue = [...direct];
71
+ while (queue.length > 0) {
72
+ const name = queue.shift();
73
+ if (!name || closure.has(name)) continue;
74
+ closure.add(name);
75
+ const nested = await WatchRootResolver.#readManifestLibDeps(path.join(libsContainer, name, "akan.lib.json"));
76
+ // An unsynced lib's own dependencies are unknowable, so no root can be ruled out.
77
+ if (!nested) return null;
78
+ queue.push(...nested);
79
+ }
80
+ return [...closure];
81
+ }
82
+
83
+ static async #readManifestLibDeps(manifestPath: string): Promise<string[] | null> {
84
+ try {
85
+ const parsed = JSON.parse(await readFile(manifestPath, "utf8")) as { libDeps?: unknown };
86
+ if (!Array.isArray(parsed.libDeps)) return null;
87
+ return parsed.libDeps.filter((name): name is string => typeof name === "string" && name.length > 0);
88
+ } catch {
89
+ // Missing before the first sync, and unparseable while sync is mid-write.
90
+ return null;
91
+ }
92
+ }
40
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.96",
3
+ "version": "3.0.0-alpha.97",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "3.0.0-alpha.96",
47
+ "akanjs": "3.0.0-alpha.97",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "dayjs": "^1.11.20",