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
5 changes: 5 additions & 0 deletions .changeset/cli-repository-template-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@croco/cli": patch
---

Generated repository and entity templates now align with the public `@croco/repository-core` interface contract.
4 changes: 2 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ Run from any directory inside a Croco workspace. The CLI automatically detects `
Creates a single source file under `apps/api-server/src/`:

- `croco make controller User` creates `apps/api-server/src/controllers/UserController.ts` with `@Controller`, `@Get`, `@Post`, `@Put`, `@Delete` stubs
- `croco make repository User` creates a class extending `Repository<UserEntity, string>`
- `croco make entity User` creates an `@Entity()` class with `id`, `createdAt`, `updatedAt`
- `croco make repository User` creates a class implementing `Repository<UserEntity, string>`
- `croco make entity User` creates a plain entity class with `id`, `createdAt`, `updatedAt`
- `croco make event OrderShipped` creates a `DomainEvent` subclass
- `croco make listener SendEmail` creates an event handler registered via `@RegisterEventHandler`

Expand Down
27 changes: 21 additions & 6 deletions packages/cli/src/commands/createDomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,18 +175,33 @@ export class ${className}Service {
}

function repositoryTemplate(className: string): string {
return `import { Repository } from "@croco/repository-core";
return `import type { Repository } from "@croco/repository-core";
import type { ${className}Entity } from "./${className}Entity";

export class ${className}Repository extends Repository<${className}Entity, string> {}
export class ${className}Repository implements Repository<${className}Entity, string> {
async findById(id: string): Promise<${className}Entity | null> {
void id;
return null;
}

async findByIds(ids: readonly string[]): Promise<ReadonlyArray<${className}Entity>> {
void ids;
return [];
}

async save(entity: ${className}Entity): Promise<${className}Entity> {
return entity;
}

async deleteById(id: string): Promise<void> {
void id;
}
}
`;
}

function entityTemplate(className: string): string {
return `import { Entity } from "@croco/repository-core";

@Entity()
export class ${className}Entity {
return `export class ${className}Entity {
id!: string;
createdAt!: Date;
updatedAt!: Date;
Expand Down
5 changes: 1 addition & 4 deletions packages/cli/src/commands/makeEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,7 @@ export async function generateEntity(
"entities",
`${className}Entity.ts`,
);
const content = `import { Entity } from "@croco/repository-core";

@Entity()
export class ${className}Entity {
const content = `export class ${className}Entity {
id!: string;
createdAt!: Date;
updatedAt!: Date;
Expand Down
22 changes: 20 additions & 2 deletions packages/cli/src/commands/makeRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,28 @@ export async function generateRepository(
"repositories",
`${className}Repository.ts`,
);
const content = `import { Repository } from "@croco/repository-core";
const content = `import type { Repository } from "@croco/repository-core";
import type { ${className}Entity } from "../entities/${className}Entity";

export class ${className}Repository extends Repository<${className}Entity, string> {}
export class ${className}Repository implements Repository<${className}Entity, string> {
async findById(id: string): Promise<${className}Entity | null> {
void id;
return null;
}

async findByIds(ids: readonly string[]): Promise<ReadonlyArray<${className}Entity>> {
void ids;
return [];
}

async save(entity: ${className}Entity): Promise<${className}Entity> {
return entity;
}

async deleteById(id: string): Promise<void> {
void id;
}
}
`;

const result = await fileWriterWrite(targetPath, content, { dryRun, overwrite });
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/tests/createDomain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,22 @@ describe("runCreateDomain", () => {
expect(controllerContent).toContain('@Controller("/user")');
expect(controllerContent).toContain("export class UserController");
expect(serviceContent).toContain("export class UserService");
expect(repositoryContent).toContain(
'import type { Repository } from "@croco/repository-core";',
);
expect(repositoryContent).toContain('import type { UserEntity } from "./UserEntity";');
expect(repositoryContent).toContain(
"export class UserRepository implements Repository<UserEntity, string>",
);
expect(repositoryContent).toContain("async findById(id: string): Promise<UserEntity | null>");
expect(repositoryContent).toContain(
"async findByIds(ids: readonly string[]): Promise<ReadonlyArray<UserEntity>>",
);
expect(repositoryContent).toContain("async save(entity: UserEntity): Promise<UserEntity>");
expect(repositoryContent).toContain("async deleteById(id: string): Promise<void>");
expect(repositoryContent).not.toContain("extends Repository");
expect(entityContent).not.toContain("@croco/repository-core");
expect(entityContent).not.toContain("@Entity()");
expect(entityContent).toContain("export class UserEntity");
expect(barrelContent).toContain('export { UserController } from "./UserController";');
expect(entryContent).toContain(
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/tests/makeEntity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ describe("generateEntity", () => {

expect(result?.status).toBe("created");
expect(result?.path).toBe(filePath);
expect(content).toContain('import { Entity } from "@croco/repository-core";');
expect(content).toContain("@Entity()");
expect(content).not.toContain("@croco/repository-core");
expect(content).not.toContain("@Entity()");
expect(content).toContain("export class UserProfileEntity");
expect(content).toContain("id!: string;");
expect(content).toContain("createdAt!: Date;");
Expand Down
83 changes: 81 additions & 2 deletions packages/cli/src/tests/makeRepository.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { generateEntity } from "../commands/makeEntity.js";
import { generateRepository } from "../commands/makeRepository.js";

const execFileAsync = promisify(execFile);
const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));

describe("generateRepository", () => {
it("should create a repository file", async () => {
const cwd = await createWorkspace();
Expand All @@ -21,13 +28,20 @@ describe("generateRepository", () => {

expect(result?.status).toBe("created");
expect(result?.path).toBe(filePath);
expect(content).toContain('import { Repository } from "@croco/repository-core";');
expect(content).toContain('import type { Repository } from "@croco/repository-core";');
expect(content).toContain(
'import type { UserProfileEntity } from "../entities/UserProfileEntity";',
);
expect(content).toContain(
"export class UserProfileRepository extends Repository<UserProfileEntity, string> {}",
"export class UserProfileRepository implements Repository<UserProfileEntity, string>",
);
expect(content).toContain("async findById(id: string): Promise<UserProfileEntity | null>");
expect(content).toContain(
"async findByIds(ids: readonly string[]): Promise<ReadonlyArray<UserProfileEntity>>",
);
expect(content).toContain("async save(entity: UserProfileEntity): Promise<UserProfileEntity>");
expect(content).toContain("async deleteById(id: string): Promise<void>");
expect(content).not.toContain("extends Repository");
});

it("should throw for invalid names", async () => {
Expand All @@ -36,6 +50,47 @@ describe("generateRepository", () => {
await expect(generateRepository("123User", { cwd })).rejects.toThrow("Invalid name: 123User");
});

it("should generate repository and entity code that typechecks against repository-core", async () => {
const cwd = await createWorkspace();

await generateEntity("UserProfile", { cwd });
await generateRepository("UserProfile", { cwd });

const tsconfigPath = path.join(cwd, "tsconfig.generated.json");
await fs.writeFile(
tsconfigPath,
JSON.stringify(
{
compilerOptions: {
baseUrl: ".",
module: "ESNext",
moduleResolution: "Bundler",
noEmit: true,
paths: {
"@croco/repository-core": [
path.join(
path.relative(cwd, REPO_ROOT),
"packages/repository-core/src/libs/Repository.ts",
),
],
},
skipLibCheck: true,
strict: true,
target: "ES2017",
},
include: [
"apps/api-server/src/entities/**/*.ts",
"apps/api-server/src/repositories/**/*.ts",
],
},
null,
2,
),
);

await expectGeneratedFixtureToTypecheck(tsconfigPath);
});

it("should not write files in dry-run mode", async () => {
const cwd = await createWorkspace();
const filePath = path.join(
Expand All @@ -54,6 +109,30 @@ describe("generateRepository", () => {
});
});

async function expectGeneratedFixtureToTypecheck(tsconfigPath: string): Promise<void> {
try {
await execFileAsync("pnpm", ["exec", "tsc", "-p", tsconfigPath, "--noEmit"], {
cwd: REPO_ROOT,
});
} catch (error) {
const output = getExecOutput(error);
throw new Error(`Generated fixture failed to typecheck.\n${output}`);
}
}

function getExecOutput(error: unknown): string {
if (typeof error === "object" && error !== null) {
const { message, stdout, stderr } = error as {
message?: string;
stdout?: string;
stderr?: string;
};
return [message, stdout, stderr].filter(Boolean).join("\n");
}

return String(error);
}

async function createWorkspace(): Promise<string> {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "croco-cli-repository-"));

Expand Down
Loading