From 3f72df6f91e50facb68cf47117b3397ce59de6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Be=C3=B1at=20Espi=C3=B1a?= Date: Sat, 17 Oct 2026 15:54:25 +0200 Subject: [PATCH 1/3] fix(cli): ignore and clean up folders left with only +types Deleting or renaming an aggregate or a read model left its generated +types behind. The next `bounda generate` took that folder for a module, failed with "a read model needs a view.ts with its fields" and stopped before removeOrphans could clean it up, so the app could not start until the folder was deleted by hand. Discovery now skips a folder that holds nothing but +types at any depth, and removeOrphans also removes the folders its removal leaves empty. An empty folder, or one with anything next to +types, is still reported. --- .changeset/stale-types-folders.md | 8 ++++++ docs/src/content/docs/reference/cli.md | 3 ++- packages/cli/src/generate/discover.test.ts | 29 ++++++++++++++++++++++ packages/cli/src/generate/discover.ts | 22 ++++++++++++++-- packages/cli/src/generate/write.test.ts | 25 +++++++++++++++++++ packages/cli/src/generate/write.ts | 19 +++++++++++--- 6 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 .changeset/stale-types-folders.md diff --git a/.changeset/stale-types-folders.md b/.changeset/stale-types-folders.md new file mode 100644 index 00000000..5a37b358 --- /dev/null +++ b/.changeset/stale-types-folders.md @@ -0,0 +1,8 @@ +--- +"@bounda-dev/cli": patch +--- + +`bounda generate` no longer takes a folder that holds nothing but `+types` for an aggregate or a +read model. Deleting or renaming one left its generated `+types` behind, and the next run failed +with "a read model needs a view.ts with its fields" before it could clean them up. Such a folder is +now ignored, its `+types` files are removed, and so is every folder the removal leaves empty. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 8e335920..107112c1 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -18,7 +18,8 @@ aggregates without `state.ts`. Reads the project layout and writes `.bounda/registry.ts`, `.bounda/register.d.ts`, `.bounda/types.ts` and one `+types/.ts` next to every module. Files whose content did not -change are left alone; `+types` files whose module is gone are removed. +change are left alone; `+types` files whose module is gone are removed, along with the folders that +leaves empty; a folder holding nothing but `+types` is not taken for a module. ```bash bounda generate diff --git a/packages/cli/src/generate/discover.test.ts b/packages/cli/src/generate/discover.test.ts index d20c8bc3..3b6e3b5e 100644 --- a/packages/cli/src/generate/discover.test.ts +++ b/packages/cli/src/generate/discover.test.ts @@ -275,6 +275,35 @@ describe("discoverProject on the order-app fixture", () => { expect(model.readModels[0]?.projections).toEqual([]); }); + it("ignores folders that hold nothing but +types, at any depth", async () => { + const root = await project([ + "app/domain/order/order-placed.ts", + "app/domain/payment/+types/payment-made.ts", + "app/domain/payment/commands/+types/pay.ts", + "app/read/summary/view.ts", + "app/read/summary/projections/payment/+types/payment-made.ts", + "app/read/old-summary/+types/view.ts", + "app/read/old-summary/queries/+types/get.ts", + "app/read/old-summary/projections/order/+types/order-placed.ts", + ]); + const model = await discoverProject({ root }); + expect(model.aggregates.map((aggregate) => aggregate.name)).toEqual(["order"]); + expect(model.readModels.map((readModel) => readModel.name)).toEqual(["summary"]); + expect(model.readModels[0]?.projections).toEqual([]); + }); + + it("still takes an empty folder, or one with anything next to +types, for a module", async () => { + const root = await project([ + "app/read/draft/", + "app/read/half/+types/view.ts", + "app/read/half/queries/get.ts", + ]); + expect(await problemsOf(root)).toEqual([ + "app/read/draft: a read model needs a view.ts with its fields", + "app/read/half: a read model needs a view.ts with its fields", + ]); + }); + it("ignores what is not domain/ or read/ at the application root", async () => { const root = await project([ "app/domain/order/order-placed.ts", diff --git a/packages/cli/src/generate/discover.ts b/packages/cli/src/generate/discover.ts index ab793862..94628bec 100644 --- a/packages/cli/src/generate/discover.ts +++ b/packages/cli/src/generate/discover.ts @@ -51,7 +51,8 @@ export interface DiscoverProjectFunction { (args: DiscoverProjectArgs): Promise; } -const IGNORED_DIRECTORIES: ReadonlySet = new Set(["+types", "node_modules"]); +const TYPES_DIRECTORY = "+types"; +const IGNORED_DIRECTORIES: ReadonlySet = new Set([TYPES_DIRECTORY, "node_modules"]); const IGNORED_SUFFIXES: readonly string[] = [".test.ts", ".test-d.ts", ".d.ts"]; const DOMAIN = "domain"; const READ = "read"; @@ -73,13 +74,30 @@ const isIgnored = (entry: Dirent): boolean => (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) || (entry.isFile() && IGNORED_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))); +// What the generator left behind for modules that are gone: `+types` directories and nothing else, +// at any depth. Such a directory is no aggregate or read model; the run removes it. +const holdsOnlyGenerated = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + if (entries.length === 0) return false; + for (const entry of entries) { + if (!entry.isDirectory()) return false; + if (entry.name === TYPES_DIRECTORY) continue; + if (!(await holdsOnlyGenerated(join(directory, entry.name)))) return false; + } + return true; +}; + const list = async (directory: string): Promise => { const entries = (await readdir(directory, { withFileTypes: true })).filter( (entry) => !isIgnored(entry), ); const sorted = [...entries].sort((a, b) => compareNames(a.name, b.name)); + const directories = sorted.filter((entry) => entry.isDirectory()).map((entry) => entry.name); + const leftovers = await Promise.all( + directories.map((name) => holdsOnlyGenerated(join(directory, name))), + ); return { - directories: sorted.filter((entry) => entry.isDirectory()).map((entry) => entry.name), + directories: directories.filter((_, index) => !leftovers[index]), modules: sorted .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) .map((entry) => entry.name.slice(0, -".ts".length)), diff --git a/packages/cli/src/generate/write.test.ts b/packages/cli/src/generate/write.test.ts index 0005d6f0..f62d35f8 100644 --- a/packages/cli/src/generate/write.test.ts +++ b/packages/cli/src/generate/write.test.ts @@ -81,6 +81,31 @@ describe("removeOrphans", () => { expect(await exists(join(app, "read/orders/queries/get.ts"))).toBe(true); }); + it("removes the folders it empties and keeps those that were empty already", async () => { + const root = await mkdtemp(join(tmpdir(), "bounda-orphans-")); + temporary.push(root); + const app = join(root, "app"); + await mkdir(join(app, "read/old-summary/+types"), { recursive: true }); + await mkdir(join(app, "read/old-summary/projections/order/+types"), { recursive: true }); + await mkdir(join(app, "read/summary/queries/+types"), { recursive: true }); + await mkdir(join(app, "read/draft"), { recursive: true }); + await writeFile(join(app, "read/old-summary/+types/view.ts"), ""); + await writeFile(join(app, "read/old-summary/projections/order/+types/order-placed.ts"), ""); + await writeFile(join(app, "read/summary/view.ts"), ""); + await writeFile(join(app, "read/summary/queries/+types/gone.ts"), ""); + + expect(await removeOrphans({ appDirectory: app, keep: new Set() })).toEqual([ + join(app, "read/old-summary/+types/view.ts"), + join(app, "read/old-summary/projections/order/+types/order-placed.ts"), + join(app, "read/summary/queries/+types/gone.ts"), + ]); + expect(await exists(join(app, "read/old-summary"))).toBe(false); + expect(await exists(join(app, "read/summary/queries"))).toBe(false); + expect(await exists(join(app, "read/summary/view.ts"))).toBe(true); + expect(await exists(join(app, "read/draft"))).toBe(true); + expect(await exists(app)).toBe(true); + }); + it("returns nothing for a directory that does not exist", async () => { expect( await removeOrphans({ appDirectory: join(tmpdir(), "nowhere-bounda"), keep: new Set() }), diff --git a/packages/cli/src/generate/write.ts b/packages/cli/src/generate/write.ts index dc75a206..6a952038 100644 --- a/packages/cli/src/generate/write.ts +++ b/packages/cli/src/generate/write.ts @@ -66,15 +66,24 @@ export interface RemoveOrphansFunction { (args: RemoveOrphansArgs): Promise; } +const isEmpty = async (directory: string): Promise => + (await readdir(directory)).length === 0; + +// A directory emptied by the removal goes too, so the folder of a deleted module does not outlive +// it; one that was already empty is the user's and stays. export const removeOrphans: RemoveOrphansFunction = async ({ appDirectory, keep }) => { const removed: string[] = []; - const walk = async (directory: string): Promise => { + const walk = async (directory: string): Promise => { const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + let emptied = false; for (const entry of entries) { const path = join(directory, entry.name); if (!entry.isDirectory()) continue; if (entry.name !== "+types") { - await walk(path); + if ((await walk(path)) && (await isEmpty(path))) { + await rm(path, { recursive: true }); + emptied = true; + } continue; } for (const generated of await readdir(path)) { @@ -84,8 +93,12 @@ export const removeOrphans: RemoveOrphansFunction = async ({ appDirectory, keep removed.push(file); } } - if ((await readdir(path)).length === 0) await rm(path, { recursive: true }); + if (await isEmpty(path)) { + await rm(path, { recursive: true }); + emptied = true; + } } + return emptied; }; await walk(appDirectory); return removed.sort(); From c578302b07d2372dbb86e61c4b5b2359ecc19646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Be=C3=B1at=20Espi=C3=B1a?= Date: Sat, 17 Oct 2026 16:10:43 +0200 Subject: [PATCH 2/3] docs(cli): say plainly which folders generate removes --- docs/src/content/docs/reference/cli.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 107112c1..a45516ea 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -18,8 +18,8 @@ aggregates without `state.ts`. Reads the project layout and writes `.bounda/registry.ts`, `.bounda/register.d.ts`, `.bounda/types.ts` and one `+types/.ts` next to every module. Files whose content did not -change are left alone; `+types` files whose module is gone are removed, along with the folders that -leaves empty; a folder holding nothing but `+types` is not taken for a module. +change are left alone; `+types` files whose module is gone are removed, and so are the folders +their removal empties; a folder holding nothing but `+types` is not taken for a module. ```bash bounda generate From e46529190322e56897eefaf7f7b9784b8db3b472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Be=C3=B1at=20Espi=C3=B1a?= Date: Sat, 17 Oct 2026 16:15:38 +0200 Subject: [PATCH 3/3] fix(cli): let Finder's .DS_Store sit next to stale +types Opening a folder in Finder drops a .DS_Store into it, so a deleted read model whose folder held +types and .DS_Store was still taken for a module and generation failed as before. Folder metadata no longer counts when telling leftovers apart, and a folder emptied of +types is removed with its .DS_Store. A folder with only a .DS_Store is still reported. --- .changeset/stale-types-folders.md | 5 +++-- docs/src/content/docs/reference/cli.md | 3 ++- packages/cli/src/generate/discover.test.ts | 6 +++++- packages/cli/src/generate/discover.ts | 23 ++++++++++++++-------- packages/cli/src/generate/write.test.ts | 6 +++++- packages/cli/src/generate/write.ts | 9 ++++++++- 6 files changed, 38 insertions(+), 14 deletions(-) diff --git a/.changeset/stale-types-folders.md b/.changeset/stale-types-folders.md index 5a37b358..e802ce5a 100644 --- a/.changeset/stale-types-folders.md +++ b/.changeset/stale-types-folders.md @@ -4,5 +4,6 @@ `bounda generate` no longer takes a folder that holds nothing but `+types` for an aggregate or a read model. Deleting or renaming one left its generated `+types` behind, and the next run failed -with "a read model needs a view.ts with its fields" before it could clean them up. Such a folder is -now ignored, its `+types` files are removed, and so is every folder the removal leaves empty. +with "a read model needs a view.ts with its fields" before it could clean them up. Such a folder, +with or without a Finder `.DS_Store` in it, is now ignored, its `+types` files are removed, and so +is every folder the removal leaves empty. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index a45516ea..b40140da 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -19,7 +19,8 @@ aggregates without `state.ts`. Reads the project layout and writes `.bounda/registry.ts`, `.bounda/register.d.ts`, `.bounda/types.ts` and one `+types/.ts` next to every module. Files whose content did not change are left alone; `+types` files whose module is gone are removed, and so are the folders -their removal empties; a folder holding nothing but `+types` is not taken for a module. +their removal empties; a folder holding nothing but `+types` (and Finder's `.DS_Store`) is not +taken for a module. ```bash bounda generate diff --git a/packages/cli/src/generate/discover.test.ts b/packages/cli/src/generate/discover.test.ts index 3b6e3b5e..44bac8d6 100644 --- a/packages/cli/src/generate/discover.test.ts +++ b/packages/cli/src/generate/discover.test.ts @@ -275,7 +275,7 @@ describe("discoverProject on the order-app fixture", () => { expect(model.readModels[0]?.projections).toEqual([]); }); - it("ignores folders that hold nothing but +types, at any depth", async () => { + it("ignores folders that hold nothing but +types and Finder's .DS_Store, at any depth", async () => { const root = await project([ "app/domain/order/order-placed.ts", "app/domain/payment/+types/payment-made.ts", @@ -283,8 +283,10 @@ describe("discoverProject on the order-app fixture", () => { "app/read/summary/view.ts", "app/read/summary/projections/payment/+types/payment-made.ts", "app/read/old-summary/+types/view.ts", + ["app/read/old-summary/.DS_Store", ""], "app/read/old-summary/queries/+types/get.ts", "app/read/old-summary/projections/order/+types/order-placed.ts", + ["app/read/old-summary/projections/order/.DS_Store", ""], ]); const model = await discoverProject({ root }); expect(model.aggregates.map((aggregate) => aggregate.name)).toEqual(["order"]); @@ -295,11 +297,13 @@ describe("discoverProject on the order-app fixture", () => { it("still takes an empty folder, or one with anything next to +types, for a module", async () => { const root = await project([ "app/read/draft/", + ["app/read/finder/.DS_Store", ""], "app/read/half/+types/view.ts", "app/read/half/queries/get.ts", ]); expect(await problemsOf(root)).toEqual([ "app/read/draft: a read model needs a view.ts with its fields", + "app/read/finder: a read model needs a view.ts with its fields", "app/read/half: a read model needs a view.ts with its fields", ]); }); diff --git a/packages/cli/src/generate/discover.ts b/packages/cli/src/generate/discover.ts index 94628bec..427073a7 100644 --- a/packages/cli/src/generate/discover.ts +++ b/packages/cli/src/generate/discover.ts @@ -37,6 +37,7 @@ import { typeNameOf, } from "./naming.ts"; import { createProblemCollector, type ProblemCollector } from "./problems.ts"; +import { isFolderMetadata } from "./write.ts"; export interface DiscoverProjectArgs { readonly root: string; @@ -74,17 +75,23 @@ const isIgnored = (entry: Dirent): boolean => (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) || (entry.isFile() && IGNORED_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))); -// What the generator left behind for modules that are gone: `+types` directories and nothing else, -// at any depth. Such a directory is no aggregate or read model; the run removes it. +// What the generator left behind for modules that are gone: `+types` directories and nothing else +// but folder metadata, at any depth. Such a directory is no aggregate or read model; the run +// removes it. const holdsOnlyGenerated = async (directory: string): Promise => { - const entries = await readdir(directory, { withFileTypes: true }); - if (entries.length === 0) return false; - for (const entry of entries) { + let generated = false; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (isFolderMetadata(entry.name)) continue; if (!entry.isDirectory()) return false; - if (entry.name === TYPES_DIRECTORY) continue; - if (!(await holdsOnlyGenerated(join(directory, entry.name)))) return false; + if ( + entry.name !== TYPES_DIRECTORY && + !(await holdsOnlyGenerated(join(directory, entry.name))) + ) { + return false; + } + generated = true; } - return true; + return generated; }; const list = async (directory: string): Promise => { diff --git a/packages/cli/src/generate/write.test.ts b/packages/cli/src/generate/write.test.ts index f62d35f8..a695f795 100644 --- a/packages/cli/src/generate/write.test.ts +++ b/packages/cli/src/generate/write.test.ts @@ -81,7 +81,7 @@ describe("removeOrphans", () => { expect(await exists(join(app, "read/orders/queries/get.ts"))).toBe(true); }); - it("removes the folders it empties and keeps those that were empty already", async () => { + it("removes the folders it empties, Finder's .DS_Store aside, and keeps those that were empty already", async () => { const root = await mkdtemp(join(tmpdir(), "bounda-orphans-")); temporary.push(root); const app = join(root, "app"); @@ -89,7 +89,10 @@ describe("removeOrphans", () => { await mkdir(join(app, "read/old-summary/projections/order/+types"), { recursive: true }); await mkdir(join(app, "read/summary/queries/+types"), { recursive: true }); await mkdir(join(app, "read/draft"), { recursive: true }); + await mkdir(join(app, "read/browsed"), { recursive: true }); await writeFile(join(app, "read/old-summary/+types/view.ts"), ""); + await writeFile(join(app, "read/old-summary/.DS_Store"), ""); + await writeFile(join(app, "read/browsed/.DS_Store"), ""); await writeFile(join(app, "read/old-summary/projections/order/+types/order-placed.ts"), ""); await writeFile(join(app, "read/summary/view.ts"), ""); await writeFile(join(app, "read/summary/queries/+types/gone.ts"), ""); @@ -103,6 +106,7 @@ describe("removeOrphans", () => { expect(await exists(join(app, "read/summary/queries"))).toBe(false); expect(await exists(join(app, "read/summary/view.ts"))).toBe(true); expect(await exists(join(app, "read/draft"))).toBe(true); + expect(await exists(join(app, "read/browsed/.DS_Store"))).toBe(true); expect(await exists(app)).toBe(true); }); diff --git a/packages/cli/src/generate/write.ts b/packages/cli/src/generate/write.ts index 6a952038..9e5c1997 100644 --- a/packages/cli/src/generate/write.ts +++ b/packages/cli/src/generate/write.ts @@ -30,6 +30,13 @@ export interface IsInGeneratedFunction { export const isInGenerated: IsInGeneratedFunction = (path) => path.split(/[\\/]/).some((segment) => GENERATED_SEGMENTS.has(segment)); +export interface IsFolderMetadataFunction { + (name: string): boolean; +} + +// What the file manager drops into a folder that is only browsed: it says nothing about modules. +export const isFolderMetadata: IsFolderMetadataFunction = (name) => name === ".DS_Store"; + export const writeGeneratedFile: WriteGeneratedFileFunction = async (path, content) => { if (!isGeneratedPath(path)) { throw new Error(`refusing to write ${path}: only +types and .bounda files are generated`); @@ -67,7 +74,7 @@ export interface RemoveOrphansFunction { } const isEmpty = async (directory: string): Promise => - (await readdir(directory)).length === 0; + (await readdir(directory)).every(isFolderMetadata); // A directory emptied by the removal goes too, so the folder of a deleted module does not outlive // it; one that was already empty is the user's and stays.