diff --git a/.changeset/stale-types-folders.md b/.changeset/stale-types-folders.md new file mode 100644 index 00000000..e802ce5a --- /dev/null +++ b/.changeset/stale-types-folders.md @@ -0,0 +1,9 @@ +--- +"@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, +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 8e335920..b40140da 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -18,7 +18,9 @@ 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, and so are the folders +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 d20c8bc3..44bac8d6 100644 --- a/packages/cli/src/generate/discover.test.ts +++ b/packages/cli/src/generate/discover.test.ts @@ -275,6 +275,39 @@ describe("discoverProject on the order-app fixture", () => { expect(model.readModels[0]?.projections).toEqual([]); }); + 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", + "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/.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"]); + 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/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", + ]); + }); + 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..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; @@ -51,7 +52,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 +75,36 @@ 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 +// 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 => { + 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 && + !(await holdsOnlyGenerated(join(directory, entry.name))) + ) { + return false; + } + generated = true; + } + return generated; +}; + 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..a695f795 100644 --- a/packages/cli/src/generate/write.test.ts +++ b/packages/cli/src/generate/write.test.ts @@ -81,6 +81,35 @@ describe("removeOrphans", () => { expect(await exists(join(app, "read/orders/queries/get.ts"))).toBe(true); }); + 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"); + 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 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"), ""); + + 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(join(app, "read/browsed/.DS_Store"))).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..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`); @@ -66,15 +73,24 @@ export interface RemoveOrphansFunction { (args: RemoveOrphansArgs): Promise; } +const isEmpty = async (directory: string): Promise => + (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. 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 +100,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();