From 0d3e5f8acc84210e3da57bcf26ca058ec799c5ef Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Mon, 15 Jun 2026 02:12:54 +0900 Subject: [PATCH] fix: generate repository templates against public contracts --- .../cli-repository-template-contract.md | 5 ++ packages/cli/README.md | 4 +- packages/cli/src/commands/createDomain.ts | 27 ++++-- packages/cli/src/commands/makeEntity.ts | 5 +- packages/cli/src/commands/makeRepository.ts | 22 ++++- packages/cli/src/tests/createDomain.spec.ts | 15 ++++ packages/cli/src/tests/makeEntity.spec.ts | 4 +- packages/cli/src/tests/makeRepository.spec.ts | 83 ++++++++++++++++++- 8 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 .changeset/cli-repository-template-contract.md diff --git a/.changeset/cli-repository-template-contract.md b/.changeset/cli-repository-template-contract.md new file mode 100644 index 000000000..752057252 --- /dev/null +++ b/.changeset/cli-repository-template-contract.md @@ -0,0 +1,5 @@ +--- +"@croco/cli": patch +--- + +Generated repository and entity templates now align with the public `@croco/repository-core` interface contract. diff --git a/packages/cli/README.md b/packages/cli/README.md index f62d0f2e7..cf0a6e445 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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` -- `croco make entity User` creates an `@Entity()` class with `id`, `createdAt`, `updatedAt` +- `croco make repository User` creates a class implementing `Repository` +- `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` diff --git a/packages/cli/src/commands/createDomain.ts b/packages/cli/src/commands/createDomain.ts index d834092ae..09af550b3 100644 --- a/packages/cli/src/commands/createDomain.ts +++ b/packages/cli/src/commands/createDomain.ts @@ -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> { + void ids; + return []; + } + + async save(entity: ${className}Entity): Promise<${className}Entity> { + return entity; + } + + async deleteById(id: string): Promise { + 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; diff --git a/packages/cli/src/commands/makeEntity.ts b/packages/cli/src/commands/makeEntity.ts index bf458aeb7..a8a9a3f68 100644 --- a/packages/cli/src/commands/makeEntity.ts +++ b/packages/cli/src/commands/makeEntity.ts @@ -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; diff --git a/packages/cli/src/commands/makeRepository.ts b/packages/cli/src/commands/makeRepository.ts index 3a02f0cab..452d335aa 100644 --- a/packages/cli/src/commands/makeRepository.ts +++ b/packages/cli/src/commands/makeRepository.ts @@ -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> { + void ids; + return []; + } + + async save(entity: ${className}Entity): Promise<${className}Entity> { + return entity; + } + + async deleteById(id: string): Promise { + void id; + } +} `; const result = await fileWriterWrite(targetPath, content, { dryRun, overwrite }); diff --git a/packages/cli/src/tests/createDomain.spec.ts b/packages/cli/src/tests/createDomain.spec.ts index 5deaa2965..ea984276f 100644 --- a/packages/cli/src/tests/createDomain.spec.ts +++ b/packages/cli/src/tests/createDomain.spec.ts @@ -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", + ); + expect(repositoryContent).toContain("async findById(id: string): Promise"); + expect(repositoryContent).toContain( + "async findByIds(ids: readonly string[]): Promise>", + ); + expect(repositoryContent).toContain("async save(entity: UserEntity): Promise"); + expect(repositoryContent).toContain("async deleteById(id: string): Promise"); + 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( diff --git a/packages/cli/src/tests/makeEntity.spec.ts b/packages/cli/src/tests/makeEntity.spec.ts index 9682ca050..281e93150 100644 --- a/packages/cli/src/tests/makeEntity.spec.ts +++ b/packages/cli/src/tests/makeEntity.spec.ts @@ -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;"); diff --git a/packages/cli/src/tests/makeRepository.spec.ts b/packages/cli/src/tests/makeRepository.spec.ts index 713fd10de..016959a61 100644 --- a/packages/cli/src/tests/makeRepository.spec.ts +++ b/packages/cli/src/tests/makeRepository.spec.ts @@ -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(); @@ -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 {}", + "export class UserProfileRepository implements Repository", + ); + expect(content).toContain("async findById(id: string): Promise"); + expect(content).toContain( + "async findByIds(ids: readonly string[]): Promise>", ); + expect(content).toContain("async save(entity: UserProfileEntity): Promise"); + expect(content).toContain("async deleteById(id: string): Promise"); + expect(content).not.toContain("extends Repository"); }); it("should throw for invalid names", async () => { @@ -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( @@ -54,6 +109,30 @@ describe("generateRepository", () => { }); }); +async function expectGeneratedFixtureToTypecheck(tsconfigPath: string): Promise { + 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 { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "croco-cli-repository-"));