Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/stale-types-folders.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/generate/discover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 27 additions & 2 deletions packages/cli/src/generate/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -51,7 +52,8 @@ export interface DiscoverProjectFunction {
(args: DiscoverProjectArgs): Promise<DiscoveredProject>;
}

const IGNORED_DIRECTORIES: ReadonlySet<string> = new Set(["+types", "node_modules"]);
const TYPES_DIRECTORY = "+types";
const IGNORED_DIRECTORIES: ReadonlySet<string> = new Set([TYPES_DIRECTORY, "node_modules"]);
const IGNORED_SUFFIXES: readonly string[] = [".test.ts", ".test-d.ts", ".d.ts"];
const DOMAIN = "domain";
const READ = "read";
Expand All @@ -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<boolean> => {
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<Listing> => {
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)),
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/generate/write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down
26 changes: 23 additions & 3 deletions packages/cli/src/generate/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -66,15 +73,24 @@ export interface RemoveOrphansFunction {
(args: RemoveOrphansArgs): Promise<readonly string[]>;
}

const isEmpty = async (directory: string): Promise<boolean> =>
(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<void> => {
const walk = async (directory: string): Promise<boolean> => {
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)) {
Expand All @@ -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();
Expand Down
Loading