diff --git a/packages/app-builder/cli/src/generate.ts b/packages/app-builder/cli/src/generate.ts index fc9c113f..78029f42 100644 --- a/packages/app-builder/cli/src/generate.ts +++ b/packages/app-builder/cli/src/generate.ts @@ -76,9 +76,9 @@ const workspacePath = (workspace: string): Effect.Effect { +const outputPath = (workspace: string, path: string, roots: ReadonlyArray): string => { const segments = safeSegments(path, safeOutputSegment) - const isOwned = TodoPreset.isTodoTopologyPath(path) + const isOwned = TodoPreset.isTodoTopologyPath(path, roots) if (segments === undefined || !isOwned) { throw new GenerateHostError({ reason: "generated topology contains an unsafe output path" }) } @@ -118,7 +118,7 @@ const prevalidate = async ( for (const file of [...topology.files].sort((left, right) => left.path.localeCompare(right.path))) { if (paths.has(file.path)) throw new GenerateConflictError({ path: file.path }) paths.add(file.path) - const target = outputPath(workspace, file.path) + const target = outputPath(workspace, file.path, topology.roots) const output = { content: file.content, path: file.path, target } await requireSafeDirectories(workspace, output) const existing = await existingEntry(target) diff --git a/packages/app-builder/cli/tests/protocol.test.ts b/packages/app-builder/cli/tests/protocol.test.ts index 8b7e83c0..a9ed3992 100644 --- a/packages/app-builder/cli/tests/protocol.test.ts +++ b/packages/app-builder/cli/tests/protocol.test.ts @@ -49,6 +49,22 @@ const generateRequest = (workspace: string) => ({ workspace, }, }) +const customGenerateRequest = (workspace: string) => ({ + ...generateRequest(workspace), + payload: { + ...generateRequest(workspace).payload, + intent: { + ...request.payload, + naming: { + workspace: "operations-workspace", + npmScope: "@acme", + domain: { id: "operations", name: "Operations" }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "admin-console", name: "AdminConsole" }, + }, + }, + }, +}) const main = () => Effect.promise(() => import(new URL("../src/main.js", import.meta.url).href)) @@ -166,7 +182,7 @@ effect("R16 prerequisite generates deterministic consumer output through the pub return Effect.gen(function* () { yield* Effect.promise(() => rm(workspacePath, { force: true, recursive: true })) - const input = JSON.stringify(generateRequest(workspace)) + const input = JSON.stringify(customGenerateRequest(workspace)) return yield* Effect.gen(function* () { const first = yield* invoke(["generate", "--events=jsonl"], input) @@ -195,20 +211,21 @@ effect("R16 prerequisite generates deterministic consumer output through the pub }) expect(firstTerminal.result.writtenPaths).not.toEqual([]) expect( - yield* Effect.promise(() => readFile(join(workspacePath, "apps/todo-cli/src/index.ts"), "utf8")), + yield* Effect.promise(() => readFile(join(workspacePath, "apps/admin-console/src/index.ts"), "utf8")), ).toContain("createLiveRuntime") const { Generation } = yield* publicSurface() - const direct = yield* Generation.TodoGeneration.composeTodoAtomic(Generation.TodoPreset.DefaultTodoRenderContext) - const rootPaths = new Set(Generation.TodoGeneration.WorkspaceRootFiles) - const rootFiles = direct.contributions.filter((file) => rootPaths.has(file.path)) + const direct = yield* Generation.TodoPreset.createTodoTopology( + yield* Generation.Planner.planTodo(customGenerateRequest(workspace).payload.intent), + ) + const rootFiles = direct.files.filter((file) => + Generation.TodoGeneration.WorkspaceRootFiles.some((path) => path === file.path), + ) expect(rootFiles).toHaveLength(5) expect(firstTerminal.result.writtenPaths).toEqual(expect.arrayContaining(rootFiles.map((file) => file.path))) for (const file of rootFiles) { - expect(yield* Effect.promise(() => readFile(join(workspacePath, file.path), "utf8"))).toBe( - new TextDecoder().decode(file.bytes), - ) + expect(yield* Effect.promise(() => readFile(join(workspacePath, file.path), "utf8"))).toBe(file.content) } const second = yield* invoke(["generate"], input) diff --git a/packages/app-builder/generation/package.json b/packages/app-builder/generation/package.json index 04646eac..40cd510b 100644 --- a/packages/app-builder/generation/package.json +++ b/packages/app-builder/generation/package.json @@ -4,6 +4,7 @@ "type": "module", "dependencies": { "@effectify/app-builder-contracts": "workspace:*", + "ejs": "5.0.1", "tslib": "catalog:" }, "peerDependencies": { @@ -20,6 +21,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@nx/devkit": "23.1.0", + "@types/ejs": "3.1.5", "@types/node": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/app-builder/generation/project.json b/packages/app-builder/generation/project.json index 601cbbea..c1a42714 100644 --- a/packages/app-builder/generation/project.json +++ b/packages/app-builder/generation/project.json @@ -10,7 +10,14 @@ "options": { "main": "packages/app-builder/generation/src/index.ts", "tsConfig": "packages/app-builder/generation/tsconfig.lib.json", - "outputPath": "packages/app-builder/generation/dist" + "outputPath": "packages/app-builder/generation/dist", + "assets": [ + { + "input": "packages/app-builder/generation/src/templates/assets", + "glob": "**/*", + "output": "src/templates/assets" + } + ] } }, "typecheck": { @@ -22,6 +29,7 @@ }, "test": { "executor": "nx:run-commands", + "dependsOn": ["build"], "options": { "command": "../../../node_modules/.bin/vitest run --config vitest.config.mts", "cwd": "packages/app-builder/generation" diff --git a/packages/app-builder/generation/src/catalog.ts b/packages/app-builder/generation/src/catalog.ts index 3ca87254..6bdc2812 100644 --- a/packages/app-builder/generation/src/catalog.ts +++ b/packages/app-builder/generation/src/catalog.ts @@ -211,7 +211,6 @@ export const composeCatalog = < } const contributions: Array = [] - const owners = new Set() const paths = new Set() for (const id of generatorIds) { const generator = byId.get(id) @@ -220,15 +219,9 @@ export const composeCatalog = < Effect.mapError(() => new SchemaContextFailure({ boundary: "intent", reason: "schema" })), ) for (const contribution of yield* generator.render(input, context)) { - if (owners.has(contribution.owner)) { - return yield* Effect.fail( - new ContributionConflict({ identity: contribution.owner, reason: "duplicate-owner" }), - ) - } if (paths.has(contribution.path)) { return yield* Effect.fail(new ContributionConflict({ identity: contribution.path, reason: "duplicate-path" })) } - owners.add(contribution.owner) paths.add(contribution.path) contributions.push(Object.freeze({ ...contribution })) } diff --git a/packages/app-builder/generation/src/generators/block.ts b/packages/app-builder/generation/src/generators/block.ts index 7d49b276..8209c3d0 100644 --- a/packages/app-builder/generation/src/generators/block.ts +++ b/packages/app-builder/generation/src/generators/block.ts @@ -1,4 +1,5 @@ -import { todoTemplateContent } from "../templates/todo/index.js" +import { renderTemplate, templateAsset, templateSubstitutions } from "../templates.js" +import { DefaultTodoRenderContext } from "./todo-v1-atomic.js" export type TodoGenerationBlockId = "model" | "port" | "event" | "use-case" | "integration-adapter" | "presentation" @@ -6,6 +7,7 @@ export interface GenerationBlockFile { readonly content: string readonly owner: string readonly path: string + readonly template?: import("../templates.js").TemplateAsset } export interface GenerationBlock { @@ -24,10 +26,23 @@ interface Definition { readonly requires: ReadonlyArray } -export const fromTodoTemplate = (path: string): Readonly<{ content: string; path: string }> => ({ - content: todoTemplateContent(path), - path, -}) +export const fromTodoTemplate = ( + path: string, +): Readonly<{ content: string; path: string; template?: import("../templates.js").TemplateAsset }> => { + const target = DefaultTodoRenderContext.packages.find(({ root }) => path.startsWith(`${root}/`))! + const layer = `layer-${DefaultTodoRenderContext.packages.indexOf(target) + 1}` + const relative = path + .slice(target.root.length + 1) + .replace("todo-runtime.test.ts", "__entityId__-runtime.test.ts") + .replace("todo.test.ts", "__entityId__.test.ts") + const template = templateAsset({ + directory: `blueprint/${layer}`, + group: `todo-v1-${layer}`, + sourcePath: `__targetRoot__/${relative}.template`, + substitutions: templateSubstitutions(DefaultTodoRenderContext, { targetRoot: target.root }), + }) + return Object.freeze({ content: renderTemplate(template), path, template }) +} export const defineTodoGenerationBlock = (definition: Definition): GenerationBlock => { const owner = `@effectify/app-builder/${definition.id}/1` diff --git a/packages/app-builder/generation/src/generators/event.ts b/packages/app-builder/generation/src/generators/event.ts index c2e5f064..36fee61f 100644 --- a/packages/app-builder/generation/src/generators/event.ts +++ b/packages/app-builder/generation/src/generators/event.ts @@ -1,11 +1,8 @@ -import { defineTodoGenerationBlock } from "./block.js" -import { domainTemplate } from "../templates/todo/domain.js" +import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" import { defineTodoGenerator } from "./todo.js" -const eventTemplate = `import * as Data from "effect/Data"\nimport type { Todo } from "./model.js"\n\n${domainTemplate.slice(domainTemplate.indexOf("export type TodoEvent"))}` - export const eventGenerator = defineTodoGenerator({ - files: [{ content: eventTemplate, relativePath: "src/event.ts" }], + files: [{ relativePath: "src/event.ts", sourcePath: "__targetRoot__/src/event.ts.template" }], id: "event", packageId: "domain", provides: ["todo-event"], @@ -13,7 +10,7 @@ export const eventGenerator = defineTodoGenerator({ }) export const eventBlock = defineTodoGenerationBlock({ - files: [{ content: 'export type { TodoEvent } from "./index.js"\n', path: "packages/todo/domain/src/events.ts" }], + files: [fromTodoTemplate("packages/todo/domain/src/events.ts")], id: "event", manifestPath: "packages/todo/domain/.effectify/generation/event.json", provides: ["todo.events"], diff --git a/packages/app-builder/generation/src/generators/index.ts b/packages/app-builder/generation/src/generators/index.ts index 0155a1ed..38d848af 100644 --- a/packages/app-builder/generation/src/generators/index.ts +++ b/packages/app-builder/generation/src/generators/index.ts @@ -11,6 +11,7 @@ import { TodoGenerationBlockIds, todoSurfaceInput, type TodoGenerationBlockId } import { packageSurfaceGenerator, workspaceSurfaceGenerator } from "./surfaces.js" import { useCaseBlock, useCaseGenerator } from "./use-case.js" import { isTodoV1Context, TodoV1AtomicCatalog, todoV1GeneratorIds } from "./todo-v1-atomic.js" +export { DefaultTodoRenderContext } from "./todo-v1-atomic.js" export { packageSurfaceGenerator, SurfaceCatalog, WorkspaceRootFiles, workspaceSurfaceGenerator } from "./surfaces.js" export type { WorkspaceRootFile } from "./surfaces.js" @@ -65,6 +66,21 @@ export const composeTodoAtomic = (context: unknown, selected: ReadonlyArray = TodoGenerationBlockIds) => + decodeRenderContext(context).pipe( + Effect.flatMap((decoded) => { + const invalid = selected.find((id) => !isTodoGenerationBlockId(id)) + return invalid === undefined + ? composeCatalog({ + catalog: TodoV1AtomicCatalog, + context: decoded, + input: undefined, + selected: todoV1GeneratorIds(selected.filter(isTodoGenerationBlockId)), + }) + : Effect.fail(new CapabilityGraphFailure({ capability: invalid, reason: "missing-capability" })) + }), + ) + const blockById: ReadonlyMap = new Map([ [modelBlock.id, modelBlock], [portBlock.id, portBlock], diff --git a/packages/app-builder/generation/src/generators/integration-adapter.ts b/packages/app-builder/generation/src/generators/integration-adapter.ts index 29dbca1f..affbb6c2 100644 --- a/packages/app-builder/generation/src/generators/integration-adapter.ts +++ b/packages/app-builder/generation/src/generators/integration-adapter.ts @@ -1,9 +1,8 @@ -import { infrastructureTemplate } from "../templates/todo/infrastructure.js" import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" import { defineTodoGenerator } from "./todo.js" export const integrationAdapterGenerator = defineTodoGenerator({ - files: [{ content: infrastructureTemplate, relativePath: "src/adapter.ts" }], + files: [{ relativePath: "src/adapter.ts", sourcePath: "__targetRoot__/src/adapter.ts.template" }], id: "integration-adapter", packageId: "infrastructure", provides: ["todo-file-adapter"], diff --git a/packages/app-builder/generation/src/generators/model.ts b/packages/app-builder/generation/src/generators/model.ts index e29f6e79..2dd0f872 100644 --- a/packages/app-builder/generation/src/generators/model.ts +++ b/packages/app-builder/generation/src/generators/model.ts @@ -1,11 +1,11 @@ -import { domainTemplate } from "../templates/todo/domain.js" import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" import { defineTodoGenerator } from "./todo.js" -const modelTemplate = `import * as Data from "effect/Data"\nimport * as Schema from "effect/Schema"\n\n${domainTemplate.slice(domainTemplate.indexOf("export const TodoId"), domainTemplate.indexOf("export type TodoEvent"))}` - export const modelGenerator = defineTodoGenerator({ - files: [{ content: modelTemplate, relativePath: "src/model.ts" }], + files: [ + { relativePath: "src/model.ts", sourcePath: "__targetRoot__/src/model.ts.template" }, + { relativePath: "tests/model.test.ts", sourcePath: "__targetRoot__/tests/model.test.ts.template" }, + ], id: "model", packageId: "domain", provides: ["todo-model"], diff --git a/packages/app-builder/generation/src/generators/port.ts b/packages/app-builder/generation/src/generators/port.ts index 353d4de7..d515136d 100644 --- a/packages/app-builder/generation/src/generators/port.ts +++ b/packages/app-builder/generation/src/generators/port.ts @@ -1,11 +1,8 @@ import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" -import { applicationTemplate } from "../templates/todo/application.js" import { defineTodoGenerator } from "./todo.js" -const portTemplate = `import * as Context from "effect/Context"\nimport * as Effect from "effect/Effect"\nimport { TodoId, type Todo, type TodoEvent as Event, TodoIdExhausted, TodoPersistenceError } from "../../domain/src/index.js"\n\n${applicationTemplate.slice(applicationTemplate.indexOf("export interface TodoRepositoryApi"), applicationTemplate.indexOf("type TodoFailure"))}` - export const portGenerator = defineTodoGenerator({ - files: [{ content: portTemplate, relativePath: "src/port.ts" }], + files: [{ relativePath: "src/port.ts", sourcePath: "__targetRoot__/src/port.ts.template" }], id: "port", packageId: "application", provides: ["todo-port"], diff --git a/packages/app-builder/generation/src/generators/presentation.ts b/packages/app-builder/generation/src/generators/presentation.ts index 8bb35bc1..06dff95d 100644 --- a/packages/app-builder/generation/src/generators/presentation.ts +++ b/packages/app-builder/generation/src/generators/presentation.ts @@ -1,9 +1,8 @@ -import { cliTemplate } from "../templates/todo/cli.js" import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" import { defineTodoGenerator } from "./todo.js" export const presentationGenerator = defineTodoGenerator({ - files: [{ content: cliTemplate, relativePath: "src/presentation.ts" }], + files: [{ relativePath: "src/presentation.ts", sourcePath: "__targetRoot__/src/entrypoint.ts.template" }], id: "presentation", packageId: "presentation", provides: ["todo-cli-presentation"], diff --git a/packages/app-builder/generation/src/generators/surfaces.ts b/packages/app-builder/generation/src/generators/surfaces.ts index 378d58b1..6d1647c4 100644 --- a/packages/app-builder/generation/src/generators/surfaces.ts +++ b/packages/app-builder/generation/src/generators/surfaces.ts @@ -14,6 +14,13 @@ import { type RenderContext, RenderFailure, } from "../kernel.js" +import { + renderTemplate, + templateAsset, + templateSubstitutions, + type TemplateAsset, + type TemplateSubstitutions, +} from "../templates.js" export const WorkspaceRootFiles = [ "nx.json", @@ -37,7 +44,6 @@ const PackageSurfaceInputSchema = Schema.Struct({ export interface PackageSurfaceInput extends Schema.Schema.Type {} -const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n` const digest = (content: string) => sourceDigest(`sha256:${createHash("sha256").update(content).digest("hex")}`) const contribution = ( content: string, @@ -45,6 +51,7 @@ const contribution = ( packageId: FileContribution["package"], path: string, surface: string, + template: TemplateAsset, ) => Object.freeze({ bytes: new TextEncoder().encode(content), @@ -54,65 +61,35 @@ const contribution = ( path: safeRelativePath(path), sourceDigest: digest(content), surface: identifier(surface), + template, }) -const workspaceFiles = (context: RenderContext): Readonly> => ({ - "nx.json": json({ - defaultBase: "HEAD", - plugins: [ - { plugin: "@nx/js/typescript", options: { typecheck: { targetName: "typecheck" } } }, - { plugin: "@nx/vitest", options: { testTargetName: "test" } }, - ], - }), - "package.json": json({ - name: `${context.workspace.npmScope}/${context.workspace.name}`, - packageManager: "pnpm@10.14.0", - private: true, - scripts: { - build: "pnpm exec tsc -p tsconfig.build.json", - test: "pnpm exec vitest run --config vitest.config.mts", - typecheck: "pnpm exec tsc --noEmit -p tsconfig.build.json", - }, - devDependencies: { - "@nx/js": "23.1.0", - "@nx/vitest": "23.1.0", - nx: "23.1.0", - typescript: "6.0.3", - vitest: "4.1.10", - }, - }), - "pnpm-workspace.yaml": "packages:\n - apps/*\n - packages/*/*\n", - "tsconfig.build.json": json({ - compilerOptions: { - module: "NodeNext", - moduleResolution: "NodeNext", - strict: true, - target: "ES2022", - types: ["node"], - }, - include: ["apps/**/src/**/*.ts", "packages/**/src/**/*.ts"], - }), - "vitest.config.mts": - 'import { defineConfig } from "vitest/config"\n\nexport default defineConfig({ test: { environment: "node", watch: false } })\n', -}) +const workspaceSubstitutions = (context: RenderContext): TemplateSubstitutions => templateSubstitutions(context) export const workspaceSurfaceGenerator: AtomicGenerator = { InputSchema: PackageSurfaceInputSchema, id: identifier("workspace-surface"), provides: capabilities("workspace-surface"), render: (_input, context): Effect.Effect, GenerationFailure> => { - const files = workspaceFiles(context) + const substitutions = workspaceSubstitutions(context) return Effect.succeed( Object.freeze( - WorkspaceRootFiles.map((path) => - contribution( - files[path], + WorkspaceRootFiles.map((path) => { + const template = templateAsset({ + directory: "generic/workspace", + group: "workspace-surface", + sourcePath: `${path}.template`, + substitutions, + }) + return contribution( + renderTemplate(template), `workspace-surface-${path.replace(/[^a-z0-9]+/g, "-")}`, "workspace", path, "workspace", - ), - ), + template, + ) + }), ), ) }, @@ -125,15 +102,6 @@ const safeExport = (entry: PackageSurfaceInput["packages"][number]["exports"][nu /^\.\/[A-Za-z0-9][A-Za-z0-9./_-]*$/.test(entry.from) && !entry.from.includes("..") -const packageJson = (target: PackageTarget, dependencies: ReadonlyArray) => - json({ - name: target.name, - private: true, - type: "module", - exports: { ".": "./src/index.ts" }, - dependencies: Object.fromEntries(dependencies.map((dependency) => [dependency.name, "workspace:*"]).sort()), - }) - export const packageSurfaceGenerator: AtomicGenerator = { InputSchema: PackageSurfaceInputSchema, id: identifier("package-surface"), @@ -160,19 +128,49 @@ export const packageSurfaceGenerator: AtomicGenerator = { } const owner = `package-surface-${target.id}` const resolved = dependencies.filter((dependency): dependency is PackageTarget => dependency !== undefined) - const barrel = [...definition.exports] - .sort((left, right) => left.name.localeCompare(right.name) || left.from.localeCompare(right.from)) - .map((entry) => `export { ${entry.name} } from "${entry.from}"\n`) - .join("") + const substitutions = templateSubstitutions(context, { + dependenciesJson: JSON.stringify( + Object.fromEntries(resolved.map((dependency) => [dependency.name, "workspace:*"]).sort()), + null, + 2, + ), + exportsJson: JSON.stringify( + [...definition.exports].sort( + (left, right) => left.name.localeCompare(right.name) || left.from.localeCompare(right.from), + ), + ), + packageName: target.name, + targetRoot: target.root, + }) + const manifestTemplate = templateAsset({ + directory: "generic/package", + group: `package-surface-${target.id}`, + sourcePath: "__targetRoot__/package.json.template", + substitutions, + }) + const barrelTemplate = templateAsset({ + directory: "generic/package", + group: `package-surface-${target.id}`, + sourcePath: "__targetRoot__/src/index.ts.template", + substitutions, + }) return Effect.succeed([ contribution( - packageJson(target, resolved), + renderTemplate(manifestTemplate), `${owner}-manifest`, target.id, `${target.root}/package.json`, "package-surface", + manifestTemplate, + ), + contribution( + renderTemplate(barrelTemplate), + `${owner}-barrel`, + target.id, + `${target.root}/src/index.ts`, + "package-surface", + barrelTemplate, ), - contribution(barrel, `${owner}-barrel`, target.id, `${target.root}/src/index.ts`, "package-surface"), ]) }).pipe(Effect.map((files) => Object.freeze(files.flat()))) }, diff --git a/packages/app-builder/generation/src/generators/todo-v1-atomic.ts b/packages/app-builder/generation/src/generators/todo-v1-atomic.ts index 335582a3..0ada1398 100644 --- a/packages/app-builder/generation/src/generators/todo-v1-atomic.ts +++ b/packages/app-builder/generation/src/generators/todo-v1-atomic.ts @@ -2,43 +2,25 @@ import { createHash } from "node:crypto" import * as Effect from "effect/Effect" import * as Schema from "effect/Schema" import * as Kernel from "../kernel.js" -import { todoTemplateContent } from "../templates/todo/index.js" -const json = (source: string): string => `${JSON.stringify(JSON.parse(source), null, 2)}\n` -const rootFiles = { - "nx.json": json( - '{"defaultBase":"HEAD","plugins":[{"plugin":"@nx/js/typescript","options":{"typecheck":{"targetName":"typecheck"}}},{"plugin":"@nx/vitest","options":{"testTargetName":"test"}}]}', - ), - "package.json": json( - '{"name":"@effectify/todo-workspace","packageManager":"pnpm@10.14.0","private":true,"scripts":{"build":"pnpm exec tsc -p tsconfig.build.json","test":"pnpm exec vitest run --config vitest.config.mts","typecheck":"pnpm exec tsc --noEmit -p tsconfig.build.json"},"devDependencies":{"@effect/vitest":"catalog:","@types/node":"catalog:","@nx/js":"catalog:","@nx/vitest":"23.1.0","effect":"catalog:","nx":"23.1.0","typescript":"catalog:","vitest":"catalog:"}}', - ), - "pnpm-workspace.yaml": - 'packages:\n - apps/*\n - packages/*/*\n\ncatalog:\n "@effect/vitest": 4.0.0-beta.102\n "@nx/js": 23.1.0\n "@types/node": 20.19.25\n effect: 4.0.0-beta.102\n typescript: 6.0.3\n vitest: 4.1.10\n', - "tsconfig.build.json": json( - '{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","rootDir":".","skipLibCheck":true,"strict":true,"target":"ES2022","types":["node"]},"include":["apps/**/src/**/*.ts","packages/**/src/**/*.ts"]}', - ), - "vitest.config.mts": - 'import { defineConfig } from "vitest/config"\n\nexport default defineConfig({\n test: {\n environment: "node",\n include: ["apps/**/tests/**/*.test.ts", "packages/**/tests/**/*.test.ts"],\n watch: false,\n },\n})\n', -} as const -const canonicalTemplate = (path: string): string => - todoTemplateContent(path) - .replaceAll( - 'import * as Application from "../../../packages/todo/application/src/index.js"\nimport * as Infrastructure from "../../../packages/todo/infrastructure/src/index.js"\nimport type { TodoEvent } from "../../../packages/todo/domain/src/index.js"', - 'import * as Application from "@effectify/todo-application"\nimport type { TodoEvent } from "@effectify/todo-domain"\nimport * as Infrastructure from "@effectify/todo-infrastructure"', - ) - .replaceAll( - 'import * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\nimport * as Application from "../../application/src/index.js"', - 'import * as Application from "@effectify/todo-application"\nimport * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"', - ) - .replaceAll("../../application/src/index.js", "@effectify/todo-application") - .replaceAll("../../domain/src/index.js", "@effectify/todo-domain") +import { renderTemplate, templateAsset, templateSubstitutions } from "../templates.js" const contribution = ( - content: string, + context: Kernel.RenderContext, + sourcePath: string, owner: string, packageId: Kernel.FileContribution["package"], path: string, surface: string, -): Kernel.FileContribution => - Object.freeze({ +): Kernel.FileContribution => { + const [directory, ...relativeSource] = sourcePath.split("/") + const targetRoot = packageId === "workspace" ? undefined : context.packages.find(({ id }) => id === packageId)?.root + const template = templateAsset({ + directory: `blueprint/${directory}`, + group: `todo-v1-${packageId}`, + sourcePath: relativeSource.join("/"), + substitutions: templateSubstitutions(context, targetRoot === undefined ? {} : { targetRoot }), + }) + const content = renderTemplate(template) + return Object.freeze({ bytes: new TextEncoder().encode(content), mode: "100644", owner: Kernel.identifier(owner), @@ -46,19 +28,22 @@ const contribution = ( path: Kernel.safeRelativePath(path), sourceDigest: Kernel.sourceDigest(`sha256:${createHash("sha256").update(content).digest("hex")}`), surface: Kernel.identifier(surface), + template, }) +} const workspace: Kernel.AtomicGenerator = { InputSchema: Schema.Unknown, id: Kernel.identifier("todo-v1-workspace"), provides: Kernel.capabilities("todo-v1-workspace"), requires: Kernel.capabilities(), version: "1", - render: () => + render: (_input, context) => Effect.succeed( Object.freeze( - Object.entries(rootFiles).map(([path, content]) => + ["nx.json", "package.json", "pnpm-workspace.yaml", "tsconfig.build.json", "vitest.config.mts"].map((path) => contribution( - content, + context, + `workspace/${path}.template`, `workspace-surface-${path.replace(/[^a-z0-9]+/g, "-")}`, "workspace", path, @@ -68,11 +53,6 @@ const workspace: Kernel.AtomicGenerator = { ), ), } -const packageScripts = { - build: "pnpm -w exec tsc -p tsconfig.build.json", - test: "pnpm -w exec vitest run --config vitest.config.mts", - typecheck: "pnpm -w exec tsc --noEmit -p tsconfig.build.json", -} const definitions = [ ["domain", [], "todo-v1-domain-surface"], ["application", ["domain"], "todo-v1-application-surface"], @@ -93,17 +73,26 @@ const packageSurface = ( const target = context.packages.find((candidate) => candidate.id === id) const resolved = dependencies.map((dependency) => context.packages.find((candidate) => candidate.id === dependency)) if (target === undefined || resolved.some((dependency) => dependency === undefined)) - return Effect.fail(new Kernel.RenderFailure({ generatorId: capability, reason: "unsafe-path" })) - const links = resolved.filter((dependency): dependency is Kernel.PackageTarget => dependency !== undefined) - const manifest = json( - `{"name":${JSON.stringify(target.name)},"private":true,"type":"module","exports":{".":"./src/index.ts"},"scripts":${JSON.stringify(packageScripts)},"dependencies":${JSON.stringify(Object.fromEntries([["effect", "catalog:"], ...links.map((dependency) => [dependency.name, "workspace:*"])]))}}`, - ) + return Effect.fail( + new Kernel.RenderFailure({ + generatorId: capability, + reason: "unsafe-path", + }), + ) const owner = `package-surface-${id}` return Effect.succeed( Object.freeze([ - contribution(manifest, `${owner}-manifest`, target.id, `${target.root}/package.json`, "package-surface"), contribution( - canonicalTemplate(`${target.root}/src/index.ts`), + context, + `${id === "domain" ? "layer-1" : id === "application" ? "layer-2" : id === "infrastructure" ? "layer-3" : "layer-4"}/__targetRoot__/package.json.template`, + `${owner}-manifest`, + target.id, + `${target.root}/package.json`, + "package-surface", + ), + contribution( + context, + `${id === "domain" ? "layer-1" : id === "application" ? "layer-2" : id === "infrastructure" ? "layer-3" : "layer-4"}/__targetRoot__/src/index.ts.template`, `${owner}-barrel`, target.id, `${target.root}/src/index.ts`, @@ -113,21 +102,30 @@ const packageSurface = ( ) }, }) -const templateTest = (packageId: string, path: string) => [packageId, path, canonicalTemplate(path)] as const +const templateTest = (packageId: string, sourcePath: string, relativePath: string) => + [packageId, sourcePath, relativePath] as const const leafFiles = { - model: templateTest("domain", "packages/todo/domain/tests/todo.test.ts"), - event: ["domain", "packages/todo/domain/src/events.ts", 'export type { TodoEvent } from "./index.js"\n'], + model: templateTest( + "domain", + "layer-1/__targetRoot__/tests/__entityId__.test.ts.template", + "tests/__entityId__.test.ts", + ), + event: templateTest("domain", "layer-1/__targetRoot__/src/events.ts.template", "src/events.ts"), port: ["application", undefined, undefined], - "use-case": [ - "application", - "packages/todo/application/src/use-case.ts", - 'export { TodoApplication } from "./index.js"\n', - ], - "integration-adapter": templateTest("infrastructure", "packages/todo/infrastructure/tests/todo-runtime.test.ts"), - presentation: templateTest("presentation", "apps/todo-cli/tests/todo.test.ts"), + "use-case": templateTest("application", "layer-2/__targetRoot__/src/use-case.ts.template", "src/use-case.ts"), + "integration-adapter": templateTest( + "infrastructure", + "layer-3/__targetRoot__/tests/__entityId__-runtime.test.ts.template", + "tests/__entityId__-runtime.test.ts", + ), + presentation: templateTest( + "presentation", + "layer-4/__targetRoot__/tests/__entityId__.test.ts.template", + "tests/__entityId__.test.ts", + ), } as const const leaf = (id: keyof typeof leafFiles, requires: ReadonlyArray): Kernel.AtomicGenerator => { - const [packageId, path, content] = leafFiles[id] + const [packageId, sourcePath, relativePath] = leafFiles[id] return { InputSchema: Schema.Unknown, id: Kernel.identifier(`todo-v1-${id}`), @@ -137,19 +135,25 @@ const leaf = (id: keyof typeof leafFiles, requires: ReadonlyArray): Kern render: (_input, context) => { const target = context.packages.find((candidate) => candidate.id === packageId) if (target === undefined) - return Effect.fail(new Kernel.RenderFailure({ generatorId: `todo-v1-${id}`, reason: "unsafe-path" })) + return Effect.fail( + new Kernel.RenderFailure({ + generatorId: `todo-v1-${id}`, + reason: "unsafe-path", + }), + ) + if (sourcePath === undefined || relativePath === undefined) return Effect.succeed(Object.freeze([])) + const path = `${target.root}/${relativePath.replace("__entityId__", context.entity.id)}` return Effect.succeed( - path === undefined || content === undefined - ? Object.freeze([]) - : Object.freeze([ - contribution( - content, - `todo-${id}-${path.replace(/[^a-z0-9]+/g, "-")}`, - target.id, - path, - "todo-capability", - ), - ]), + Object.freeze([ + contribution( + context, + sourcePath, + `todo-${id}-${path.replace(/[^a-z0-9]+/g, "-")}`, + target.id, + path, + "todo-capability", + ), + ]), ) }, } @@ -163,6 +167,23 @@ const port = leaf("port", ["todo-v1-model", "todo-v1-application-surface"]) const useCase = leaf("use-case", ["todo-v1-port", "todo-v1-application-surface"]) const adapter = leaf("integration-adapter", ["todo-v1-use-case", "todo-v1-infrastructure-surface"]) const presentation = leaf("presentation", ["todo-v1-integration-adapter", "todo-v1-presentation-surface"]) +export const DefaultTodoRenderContext = Object.freeze({ + version: "effectify.render-context/1" as const, + workspace: Object.freeze({ name: "todo-workspace", npmScope: "@effectify" }), + domain: Object.freeze({ id: "todo", name: "Todo", importName: "@effectify/todo-domain" }), + entity: Object.freeze({ id: "todo", singular: "Todo", plural: "Todos" }), + entrypoint: Object.freeze({ id: "todo-cli", name: "TodoCli", importName: "@effectify/todo-cli" }), + packages: Object.freeze( + [ + ["domain", "@effectify/todo-domain", "packages/todo/domain"], + ["application", "@effectify/todo-application", "packages/todo/application"], + ["infrastructure", "@effectify/todo-infrastructure", "packages/todo/infrastructure"], + ["presentation", "@effectify/todo-cli", "apps/todo-cli"], + ].map(([id, name, root]) => + Object.freeze({ id: Kernel.identifier(id!), name: name!, root: Kernel.safeRelativePath(root!) }), + ), + ), +}) export const TodoV1AtomicCatalog = Kernel.defineCatalog([ workspace, domainSurface, @@ -176,16 +197,16 @@ export const TodoV1AtomicCatalog = Kernel.defineCatalog([ adapter, presentation, ]) -const TodoV1Identity = - '{"version":"effectify.render-context/1","workspace":{"name":"todo-workspace","npmScope":"@effectify"},"domain":{"id":"domain","importName":"@effectify/todo-domain"},"entity":{"id":"todo","singular":"Todo","plural":"Todos","importName":"@effectify/todo-cli"},"packages":[{"id":"application","name":"@effectify/todo-application","root":"packages/todo/application"},{"id":"domain","name":"@effectify/todo-domain","root":"packages/todo/domain"},{"id":"infrastructure","name":"@effectify/todo-infrastructure","root":"packages/todo/infrastructure"},{"id":"presentation","name":"@effectify/todo-cli","root":"apps/todo-cli"}]}' const todoV1Identity = (context: Kernel.RenderContext): string => JSON.stringify({ version: context.version, workspace: context.workspace, domain: context.domain, entity: context.entity, + entrypoint: context.entrypoint, packages: [...context.packages].sort((left, right) => left.id.localeCompare(right.id)), }) +const TodoV1Identity = todoV1Identity(DefaultTodoRenderContext) export const isTodoV1Context = (context: Kernel.RenderContext): boolean => todoV1Identity(context) === TodoV1Identity export const todoV1GeneratorIds = (selected: ReadonlyArray) => selected.map((id) => Kernel.identifier(`todo-v1-${id}`)) diff --git a/packages/app-builder/generation/src/generators/todo.ts b/packages/app-builder/generation/src/generators/todo.ts index ba0d3ba4..e91c154b 100644 --- a/packages/app-builder/generation/src/generators/todo.ts +++ b/packages/app-builder/generation/src/generators/todo.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect" import * as Schema from "effect/Schema" import * as Kernel from "../kernel.js" import type { PackageSurfaceInput } from "./surfaces.js" +import { renderTemplate, templateAsset, templateSubstitutions, type TemplateSubstitutions } from "../templates.js" const ids = ["model", "port", "event", "use-case", "integration-adapter", "presentation"] as const export { ids as TodoGenerationBlockIds } @@ -12,7 +13,7 @@ export type TodoGenerationBlockId = (typeof ids)[number] const TodoPackageIds = ["domain", "application", "infrastructure", "presentation"] as const interface TodoGeneratorDefinition { - readonly files: ReadonlyArray> + readonly files: ReadonlyArray> readonly id: TodoGenerationBlockId readonly packageId: string readonly provides: ReadonlyArray @@ -20,15 +21,19 @@ interface TodoGeneratorDefinition { } const digest = (content: string) => Kernel.sourceDigest(`sha256:${createHash("sha256").update(content).digest("hex")}`) -const contextualizeImports = (content: string, context: Kernel.RenderContext, from: Kernel.PackageTarget) => - ["domain", "application", "infrastructure"].reduce((output, id) => { - const target = context.packages.find((candidate) => candidate.id === id) - if (target === undefined) return output - const specifier = relative(`${from.root}/src`, `${target.root}/src/index.js`).replace(/^(?!\.)/, "./") - return output - .replaceAll(`../../${id}/src/index.js`, specifier) - .replaceAll(`../../../packages/todo/${id}/src/index.js`, specifier) - }, content) +const importFrom = (context: Kernel.RenderContext, from: Kernel.PackageTarget, id: string): string => { + const target = context.packages.find((candidate) => candidate.id === id) + if (target === undefined) throw new Error(`Missing template package: ${id}`) + return relative(`${from.root}/src`, `${target.root}/src/index.js`).replace(/^(?!\.)/, "./") +} + +const substitutions = (context: Kernel.RenderContext, target: Kernel.PackageTarget): TemplateSubstitutions => + templateSubstitutions(context, { + applicationImport: importFrom(context, target, "application"), + domainImport: importFrom(context, target, "domain"), + infrastructureImport: importFrom(context, target, "infrastructure"), + targetRoot: target.root, + }) export const todoContribution = (options: { readonly content: string @@ -36,6 +41,7 @@ export const todoContribution = (options: { readonly path: string readonly surface: string readonly target: Kernel.PackageTarget + readonly template?: Kernel.FileContribution["template"] }): Kernel.FileContribution => Object.freeze({ bytes: new TextEncoder().encode(options.content), @@ -45,6 +51,7 @@ export const todoContribution = (options: { path: Kernel.safeRelativePath(options.path), sourceDigest: digest(options.content), surface: Kernel.identifier(options.surface), + ...(options.template === undefined ? {} : { template: options.template }), }) export const defineTodoGenerator = (definition: TodoGeneratorDefinition): Kernel.AtomicGenerator => ({ @@ -58,15 +65,22 @@ export const defineTodoGenerator = (definition: TodoGeneratorDefinition): Kernel } return Effect.succeed( Object.freeze( - definition.files.map((file) => - todoContribution({ - content: contextualizeImports(file.content, context, target), + definition.files.map((file) => { + const template = templateAsset({ + directory: `generic/${definition.id === "presentation" ? "interface" : definition.id}`, + group: `todo-${definition.id}`, + sourcePath: file.sourcePath, + substitutions: substitutions(context, target), + }) + return todoContribution({ + content: renderTemplate(template), owner: `todo-${definition.id}-${file.relativePath.replace(/[^a-z0-9]+/g, "-")}`, path: `${target.root}/${file.relativePath}`, surface: "todo-capability", target, - }), - ), + template, + }) + }), ), ) }, @@ -93,15 +107,28 @@ const selectedWithPrerequisites = (selected: ReadonlyArray) => ({ - domain: [...(included.has("model") ? modelExports : []), ...(included.has("event") ? ["TodoEvent"] : [])], +const exportsFor = (included: ReadonlySet, entity: string) => ({ + domain: [ + ...(included.has("model") + ? [ + `${entity}Id`, + entity, + `${entity}TextInvalid`, + `${entity}NotFound`, + `${entity}AlreadyCompleted`, + `${entity}IdExhausted`, + `${entity}PersistenceError`, + ] + : []), + ...(included.has("event") ? [`${entity}Event`] : []), + ], application: [ - ...(included.has("port") ? ["TodoRepository", "TodoClock", "TodoIdGenerator", "TodoEvents"] : []), - ...(included.has("use-case") ? ["TodoApplication", "layer"] : []), + ...(included.has("port") + ? [`${entity}Repository`, `${entity}Clock`, `${entity}IdGenerator`, `${entity}Events`] + : []), + ...(included.has("use-case") ? [`${entity}Application`, "layer"] : []), ], - infrastructure: included.has("integration-adapter") ? ["TodoTestProbe", "testLayer", "liveLayer"] : [], + infrastructure: included.has("integration-adapter") ? [`${entity}TestProbe`, "testLayer", "liveLayer"] : [], presentation: included.has("presentation") ? ["createTestRuntime", "createLiveRuntime", "renderEvent"] : [], }) @@ -115,7 +142,7 @@ export const todoSurfaceInput = ( ) { return Effect.fail(new Kernel.RenderFailure({ generatorId: "todo-surface-input", reason: "unsafe-path" })) } - const exports = exportsFor(selectedWithPrerequisites(selected)) + const exports = exportsFor(selectedWithPrerequisites(selected), context.entity.singular) const dependencies = { application: exports.application.length > 0 ? ["domain"] : [], domain: [], @@ -126,7 +153,7 @@ export const todoSurfaceInput = ( packages: TodoPackageIds.map((packageId) => ({ dependencies: dependencies[packageId], exports: exports[packageId].map((name) => ({ - from: `./${packageId === "domain" ? (name === "TodoEvent" ? "event" : "model") : packageId === "application" ? (["TodoRepository", "TodoClock", "TodoIdGenerator", "TodoEvents"].includes(name) ? "port" : "use-case") : packageId === "infrastructure" ? "adapter" : "presentation"}.js`, + from: `./${packageId === "domain" ? (name === `${context.entity.singular}Event` ? "event" : "model") : packageId === "application" ? (["Repository", "Clock", "IdGenerator", "Events"].some((suffix) => name === `${context.entity.singular}${suffix}`) ? "port" : "use-case") : packageId === "infrastructure" ? "adapter" : "presentation"}.js`, name, })), packageId, diff --git a/packages/app-builder/generation/src/generators/use-case.ts b/packages/app-builder/generation/src/generators/use-case.ts index 40188140..9b1cd004 100644 --- a/packages/app-builder/generation/src/generators/use-case.ts +++ b/packages/app-builder/generation/src/generators/use-case.ts @@ -1,11 +1,8 @@ -import { defineTodoGenerationBlock } from "./block.js" -import { applicationTemplate } from "../templates/todo/application.js" +import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" import { defineTodoGenerator } from "./todo.js" -const useCaseTemplate = `import * as Context from "effect/Context"\nimport * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\nimport * as Option from "effect/Option"\nimport * as Schema from "effect/Schema"\nimport { TodoEvent, TodoId, type Todo, TodoAlreadyCompleted, TodoIdExhausted, TodoNotFound, TodoPersistenceError, TodoTextInvalid } from "../../domain/src/index.js"\nimport { TodoClock, TodoEvents, TodoIdGenerator, TodoRepository } from "./port.js"\n\n${applicationTemplate.slice(applicationTemplate.indexOf("type TodoFailure"))}` - export const useCaseGenerator = defineTodoGenerator({ - files: [{ content: useCaseTemplate, relativePath: "src/use-case.ts" }], + files: [{ relativePath: "src/use-case.ts", sourcePath: "__targetRoot__/src/use-case.ts.template" }], id: "use-case", packageId: "application", provides: ["todo-use-case"], @@ -13,9 +10,7 @@ export const useCaseGenerator = defineTodoGenerator({ }) export const useCaseBlock = defineTodoGenerationBlock({ - files: [ - { content: 'export { TodoApplication } from "./index.js"\n', path: "packages/todo/application/src/use-case.ts" }, - ], + files: [fromTodoTemplate("packages/todo/application/src/use-case.ts")], id: "use-case", manifestPath: "packages/todo/application/.effectify/generation/use-case.json", provides: ["todo.use-case"], diff --git a/packages/app-builder/generation/src/index.ts b/packages/app-builder/generation/src/index.ts index 6cbe3584..1a55ae3e 100644 --- a/packages/app-builder/generation/src/index.ts +++ b/packages/app-builder/generation/src/index.ts @@ -8,6 +8,7 @@ export * as TodoEvolution from "./evolution.js" export * as TodoGeneration from "./generators/index.js" export * as TodoPreset from "./todo-preset.js" export * as TodoV1 from "./todo-v1.js" +export * as Templates from "./templates.js" export type { TodoPlan } from "./planner.js" export type { @@ -25,7 +26,14 @@ export type { SourceDigest, SurfaceId, } from "./kernel.js" +export type { TemplateAsset, TemplateSubstitutions } from "./templates.js" export type { CatalogComposition } from "./catalog.js" export type { ReplayProvenance } from "./provenance.js" export type { TodoGenerationBlockId } from "./generators/index.js" -export type { TodoTopology, TodoTopologyFile, TodoTopologyProject, TodoTopologyRoot } from "./todo-preset.js" +export type { + TodoPackageRole, + TodoTopology, + TodoTopologyFile, + TodoTopologyProject, + TodoTopologyRoot, +} from "./todo-preset.js" diff --git a/packages/app-builder/generation/src/intent.ts b/packages/app-builder/generation/src/intent.ts index 15b45671..0b05c079 100644 --- a/packages/app-builder/generation/src/intent.ts +++ b/packages/app-builder/generation/src/intent.ts @@ -14,10 +14,21 @@ export const Capability = Schema.Literals([ ]) export type Capability = typeof Capability.Type +const Identifier = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9-]{0,62}$/)) +const TypeName = Schema.String.check(Schema.isPattern(/^[A-Z][A-Za-z0-9]{0,62}$/)) +const Naming = Schema.Struct({ + workspace: Identifier, + npmScope: Schema.String.check(Schema.isPattern(/^@[a-z][a-z0-9-]{0,62}$/)), + domain: Schema.Struct({ id: Identifier, name: TypeName }), + entity: Schema.Struct({ id: Identifier, singular: TypeName, plural: TypeName }), + entrypoint: Schema.Struct({ id: Identifier, name: TypeName }), +}) + export const CreationIntentSchema = Schema.Struct({ version: Schema.Literal("effectify.creation-intent/1"), preset: Schema.Literal("todo"), capabilities: Schema.Array(Capability), + naming: Schema.optionalKey(Naming), }) export type CreationIntent = typeof CreationIntentSchema.Type diff --git a/packages/app-builder/generation/src/kernel.ts b/packages/app-builder/generation/src/kernel.ts index f0868036..1255d6f3 100644 --- a/packages/app-builder/generation/src/kernel.ts +++ b/packages/app-builder/generation/src/kernel.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect" import * as Schema from "effect/Schema" +import type { TemplateAsset } from "./templates.js" const IdentifierSchema = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9-]*$/)) +const TypeNameSchema = Schema.String.check(Schema.isPattern(/^[A-Z][A-Za-z0-9]{0,62}$/)) const SafeRelativePathSchema = Schema.String.check( Schema.isPattern(/^(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/), ) @@ -34,6 +36,7 @@ export interface FileContribution { readonly path: SafeRelativePath readonly sourceDigest: SourceDigest readonly surface: SurfaceId + readonly template?: TemplateAsset } export interface AtomicGenerator< Input, @@ -93,13 +96,13 @@ export type PackageTarget = Schema.Schema.Type export const RenderContextSchema = Schema.Struct({ version: Schema.Literal("effectify.render-context/1"), workspace: Schema.Struct({ name: IdentifierSchema, npmScope: ScopeSchema }), - domain: Schema.Struct({ id: IdentifierSchema, importName: PackageNameSchema }), + domain: Schema.Struct({ id: IdentifierSchema, name: TypeNameSchema, importName: PackageNameSchema }), entity: Schema.Struct({ id: IdentifierSchema, - singular: Schema.String.check(Schema.isPattern(/^[A-Z][A-Za-z0-9]*$/)), - plural: Schema.String.check(Schema.isPattern(/^[A-Z][A-Za-z0-9]*$/)), - importName: PackageNameSchema, + singular: TypeNameSchema, + plural: TypeNameSchema, }), + entrypoint: Schema.Struct({ id: IdentifierSchema, name: TypeNameSchema, importName: PackageNameSchema }), packages: Schema.Array(PackageTargetSchema), }) export type RenderContext = Schema.Schema.Type @@ -114,7 +117,7 @@ const validate = (context: RenderContext): Effect.Effect target.root)) && packages.every((target) => target.name.startsWith(`${context.workspace.npmScope}/`)) && names.has(context.domain.importName) && - names.has(context.entity.importName) + names.has(context.entrypoint.importName) return valid ? Effect.succeed( Object.freeze({ ...context, packages: Object.freeze(packages.map((target) => Object.freeze({ ...target }))) }), diff --git a/packages/app-builder/generation/src/planner.ts b/packages/app-builder/generation/src/planner.ts index 965f8334..4b519385 100644 --- a/packages/app-builder/generation/src/planner.ts +++ b/packages/app-builder/generation/src/planner.ts @@ -43,11 +43,22 @@ const selectedPlugin = (entry: CatalogEntry): SelectedPlugin | undefined => } : undefined -const canonicalIntent = (intent: CreationIntent): CreationIntent => ({ - capabilities: [...new Set(intent.capabilities)].sort(compareCapability), - preset: intent.preset, - version: intent.version, -}) +const canonicalIntent = (intent: CreationIntent): CreationIntent => + Object.freeze({ + capabilities: Object.freeze([...new Set(intent.capabilities)].sort(compareCapability)), + ...(intent.naming === undefined + ? {} + : { + naming: Object.freeze({ + ...intent.naming, + domain: Object.freeze(intent.naming.domain), + entity: Object.freeze(intent.naming.entity), + entrypoint: Object.freeze(intent.naming.entrypoint), + }), + }), + preset: intent.preset, + version: intent.version, + }) /** Produces the versioned Todo plan before any Nx adapter or workspace mutation exists. */ export const planTodo = (input: unknown): Effect.Effect => diff --git a/packages/app-builder/generation/src/templates.ts b/packages/app-builder/generation/src/templates.ts new file mode 100644 index 00000000..2b8e0663 --- /dev/null +++ b/packages/app-builder/generation/src/templates.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "node:fs" +import { isAbsolute, join, relative } from "node:path" +import { fileURLToPath } from "node:url" +import ejs from "ejs" + +export interface TemplateSubstitutions { + readonly tmpl: "" + readonly [name: string]: string +} + +export interface TemplateAsset { + readonly directory: string + readonly group: string + readonly sourcePath: string + readonly substitutions: TemplateSubstitutions +} + +const root = fileURLToPath(new URL("./templates/assets/", import.meta.url)) +const safe = (value: string): boolean => + value.length > 0 && + !isAbsolute(value) && + !value.includes("\\") && + value.split("/").every((segment) => ![".", ".."].includes(segment) && /^[A-Za-z0-9_.-]+$/.test(segment)) + +export const templateRoot = (): string => root + +type TemplateContext = Readonly<{ + workspace: { name: string; npmScope: string } + entity: { id: string; singular: string; plural: string } + packages: ReadonlyArray<{ id: string; name: string; root: string }> +}> +export const templateSubstitutions = ( + context: TemplateContext, + overrides: Readonly> = {}, +): TemplateSubstitutions => { + const targets = Object.fromEntries(context.packages.map((target) => [target.id, target])) + const domain = targets.domain ?? context.packages[0]! + const application = targets.application ?? domain + const infrastructure = targets.infrastructure ?? application + const presentation = targets.presentation ?? application + const roots = [...new Set(context.packages.map((target) => target.root.split("/")[0]))].sort() + const globs = [...new Set(context.packages.map(({ root }) => root.replace(/\/[^/]+/g, "/*")))].sort() + const typeName = (value: string) => `${value[0]?.toLowerCase()}${value.slice(1)}` + return Object.freeze({ + applicationImport: application.name, + brandNamespace: context.workspace.npmScope.slice(1).replace(/(^|-)(\w)/g, (_, _dash, char) => char.toUpperCase()), + domainImport: domain.name, + entityId: context.entity.id, + entityIdentifier: typeName(context.entity.singular), + entityPlural: context.entity.plural, + entityPluralIdentifier: typeName(context.entity.plural), + entitySingular: context.entity.singular, + infrastructureImport: infrastructure.name, + presentationImport: presentation.name, + workspacePackageGlobs: globs.map((path) => ` - ${path}`).join("\n"), + workspacePackageName: `${context.workspace.npmScope}/${context.workspace.name}`, + workspaceScope: context.workspace.npmScope, + workspaceSourceIncludes: roots.map((path) => ` "${path}/**/src/**/*.ts"`).join(",\n"), + workspaceTestIncludes: roots.map((path) => `"${path}/**/tests/**/*.test.ts"`).join(", "), + ...overrides, + tmpl: "", + }) +} + +export const templateDirectory = (group: Pick): string => { + if (!safe(group.directory)) throw new Error(`Unsafe template directory: ${group.directory}`) + const directory = join(root, group.directory) + if (relative(root, directory).startsWith("..")) throw new Error(`Template directory escapes package: ${directory}`) + return directory +} + +export const templateAsset = (options: TemplateAsset): TemplateAsset => { + if (![options.directory, options.group, options.sourcePath].every(safe)) { + throw new Error(`Unsafe template asset: ${options.sourcePath}`) + } + return Object.freeze({ + ...options, + substitutions: Object.freeze({ ...options.substitutions }), + }) +} + +/** + * Renders synchronously with the same EJS semantics consumed by Nx generateFiles. + * EJS executes JavaScript, so only validated package-owned templates may reach this boundary; substitutions are bounded immutable data. + */ +export const renderTemplate = (asset: TemplateAsset): string => { + const source = join(templateDirectory(asset), asset.sourcePath) + return ejs.render(readFileSync(source, "utf8"), asset.substitutions, { + filename: source, + }) +} + +export const templateGroups = ( + contributions: ReadonlyArray<{ readonly template?: TemplateAsset }>, +): ReadonlyArray => { + const groups = new Map() + for (const contribution of contributions) { + const asset = contribution.template + if (asset === undefined) continue + const existing = groups.get(asset.group) + if ( + existing !== undefined && + (existing.directory !== asset.directory || + JSON.stringify(existing.substitutions) !== JSON.stringify(asset.substitutions)) + ) + throw new Error(`Inconsistent template group: ${asset.group}`) + groups.set(asset.group, asset) + } + return Object.freeze([...groups.values()].sort((left, right) => left.group.localeCompare(right.group))) +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/package.json.template new file mode 100644 index 00000000..610161f9 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/package.json.template @@ -0,0 +1,16 @@ +{ + "name": "<%- domainImport %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/events.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/events.ts.template new file mode 100644 index 00000000..b1ec0bd4 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/events.ts.template @@ -0,0 +1 @@ +export type { <%- entitySingular %>Event } from "./index.js" diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..00bd7532 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/index.ts.template @@ -0,0 +1,28 @@ +import * as Data from "effect/Data" +import * as Schema from "effect/Schema" + +export const <%- entitySingular %>Id = Schema.NonEmptyString.pipe(Schema.brand("<%- brandNamespace %>.<%- entitySingular %>Id")) +export type <%- entitySingular %>Id = typeof <%- entitySingular %>Id.Type + +export const <%- entitySingular %> = Schema.Struct({ + completed: Schema.Boolean, + createdAt: Schema.String, + id: <%- entitySingular %>Id, + text: Schema.NonEmptyString, +}) +export type <%- entitySingular %> = typeof <%- entitySingular %>.Type + +export class <%- entitySingular %>TextInvalid extends Data.TaggedError("<%- entitySingular %>TextInvalid")<{ readonly reason: "empty" }> {} +export class <%- entitySingular %>NotFound extends Data.TaggedError("<%- entitySingular %>NotFound")<{ readonly id: string }> {} +export class <%- entitySingular %>AlreadyCompleted extends Data.TaggedError("<%- entitySingular %>AlreadyCompleted")<{ readonly id: string }> {} +export class <%- entitySingular %>IdExhausted extends Data.TaggedError("<%- entitySingular %>IdExhausted")<{}> {} +export class <%- entitySingular %>PersistenceError extends Data.TaggedError("<%- entitySingular %>PersistenceError")<{ + readonly operation: "read" | "write" +}> {} + +export type <%- entitySingular %>Event = Data.TaggedEnum<{ + <%- entitySingular %>Added: { readonly <%- entityIdentifier %>: <%- entitySingular %> } + <%- entitySingular %>Completed: { readonly <%- entityIdentifier %>: <%- entitySingular %> } + <%- entitySingular %>Removed: { readonly <%- entityIdentifier %>: <%- entitySingular %> } +}> +export const <%- entitySingular %>Event = Data.taggedEnum<<%- entitySingular %>Event>() diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/tests/__entityId__.test.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/tests/__entityId__.test.ts.template new file mode 100644 index 00000000..03dd4dc2 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/tests/__entityId__.test.ts.template @@ -0,0 +1,7 @@ +import { expect, it } from "vitest" +import * as Schema from "effect/Schema" +import { <%- entitySingular %> } from "../src/index.js" + +it("decodes a <%- entitySingular %> schema", () => { + expect(Schema.is(<%- entitySingular %>)({ completed: false, createdAt: "2026-01-01T00:00:00.000Z", id: "<%- entityId %>-1", text: "write tests" })).toBe(true) +}) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/package.json.template new file mode 100644 index 00000000..63e14780 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/package.json.template @@ -0,0 +1,17 @@ +{ + "name": "<%- applicationImport %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "<%- domainImport %>": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..51d433fe --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/index.ts.template @@ -0,0 +1,67 @@ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { <%- entitySingular %>Event, <%- entitySingular %>Id, type <%- entitySingular %>, type <%- entitySingular %>Event as Event, <%- entitySingular %>AlreadyCompleted, <%- entitySingular %>IdExhausted, <%- entitySingular %>NotFound, <%- entitySingular %>PersistenceError, <%- entitySingular %>TextInvalid } from "<%- domainImport %>" + +export interface <%- entitySingular %>RepositoryApi { + readonly find: (id: <%- entitySingular %>Id) => Effect.Effect<<%- entitySingular %> | undefined, <%- entitySingular %>PersistenceError> + readonly list: () => Effect.Effect>, <%- entitySingular %>PersistenceError> + readonly remove: (id: <%- entitySingular %>Id) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>PersistenceError> + readonly save: (<%- entityIdentifier %>: <%- entitySingular %>) => Effect.EffectPersistenceError> +} +export class <%- entitySingular %>Repository extends Context.Service<<%- entitySingular %>Repository, <%- entitySingular %>RepositoryApi>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>Repository") {} + +export class <%- entitySingular %>Clock extends Context.Service<<%- entitySingular %>Clock, { readonly now: () => Effect.Effect }>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>Clock") {} +export class <%- entitySingular %>IdGenerator extends Context.Service<<%- entitySingular %>IdGenerator, { readonly next: () => Effect.Effect<<%- entitySingular %>Id, <%- entitySingular %>IdExhausted> }>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>IdGenerator") {} +export class <%- entitySingular %>Events extends Context.Service<<%- entitySingular %>Events, { readonly publish: (event: Event) => Effect.Effect }>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>Events") {} + +type <%- entitySingular %>Failure = <%- entitySingular %>AlreadyCompleted | <%- entitySingular %>IdExhausted | <%- entitySingular %>NotFound | <%- entitySingular %>PersistenceError | <%- entitySingular %>TextInvalid + +export interface <%- entitySingular %>ApplicationApi { + readonly add: (text: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> + readonly complete: (id: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> + readonly list: () => Effect.Effect>, <%- entitySingular %>PersistenceError> + readonly remove: (id: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> +} +export class <%- entitySingular %>Application extends Context.Service<<%- entitySingular %>Application, <%- entitySingular %>ApplicationApi>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>Application") {} + +const resolveId = (id: string): Effect.Effect<<%- entitySingular %>Id, <%- entitySingular %>NotFound> => + Schema.decodeUnknownEffect(<%- entitySingular %>Id)(id).pipe(Effect.mapError(() => new <%- entitySingular %>NotFound({ id }))) + +export const layer = Layer.effect(<%- entitySingular %>Application, Effect.gen(function* () { + const repository = yield* <%- entitySingular %>Repository + const clock = yield* <%- entitySingular %>Clock + const ids = yield* <%- entitySingular %>IdGenerator + const events = yield* <%- entitySingular %>Events + const find = Effect.fn("<%- entitySingular %>Application.find")(function* (rawId: string) { + const id = yield* resolveId(rawId) + const <%- entityIdentifier %> = Option.fromUndefinedOr(yield* repository.find(id)) + if (Option.isNone(<%- entityIdentifier %>)) return yield* Effect.fail(new <%- entitySingular %>NotFound({ id: rawId })) + return <%- entityIdentifier %>.value + }) + const add = Effect.fn("<%- entitySingular %>Application.add")(function* (text: string) { + const normalized = text.trim() + if (normalized.length === 0) return yield* Effect.fail(new <%- entitySingular %>TextInvalid({ reason: "empty" })) + const <%- entityIdentifier %>: <%- entitySingular %> = { completed: false, createdAt: yield* clock.now(), id: yield* ids.next(), text: normalized } + yield* repository.save(<%- entityIdentifier %>) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Added({ <%- entityIdentifier %> })) + return <%- entityIdentifier %> + }) + const complete = Effect.fn("<%- entitySingular %>Application.complete")(function* (id: string) { + const <%- entityIdentifier %> = yield* find(id) + if (<%- entityIdentifier %>.completed) return yield* Effect.fail(new <%- entitySingular %>AlreadyCompleted({ id })) + const completed = { ...<%- entityIdentifier %>, completed: true } + yield* repository.save(completed) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Completed({ <%- entityIdentifier %>: completed })) + return completed + }) + const remove = Effect.fn("<%- entitySingular %>Application.remove")(function* (id: string) { + const <%- entityIdentifier %> = yield* find(id) + yield* repository.remove(<%- entityIdentifier %>.id) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Removed({ <%- entityIdentifier %> })) + return <%- entityIdentifier %> + }) + return <%- entitySingular %>Application.of({ add, complete, list: repository.list, remove }) +})) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/use-case.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/use-case.ts.template new file mode 100644 index 00000000..efe2c2dc --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/use-case.ts.template @@ -0,0 +1 @@ +export { <%- entitySingular %>Application } from "./index.js" diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/package.json.template new file mode 100644 index 00000000..c436eaa1 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/package.json.template @@ -0,0 +1,18 @@ +{ + "name": "<%- infrastructureImport %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "<%- applicationImport %>": "workspace:*", + "<%- domainImport %>": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..3d636421 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/src/index.ts.template @@ -0,0 +1,91 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises" +import { randomUUID } from "node:crypto" +import { dirname } from "node:path" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as PubSub from "effect/PubSub" +import * as Ref from "effect/Ref" +import * as Stream from "effect/Stream" +import * as Schema from "effect/Schema" +import * as Application from "<%- applicationImport %>" +import { <%- entitySingular %>, <%- entitySingular %>Id, <%- entitySingular %>IdExhausted, <%- entitySingular %>PersistenceError, type <%- entitySingular %> as <%- entitySingular %>Model, type <%- entitySingular %>Event as Event } from "<%- domainImport %>" + +export interface <%- entitySingular %>TestProbeApi { readonly events: () => Effect.Effect> } +export class <%- entitySingular %>TestProbe extends Context.Service<<%- entitySingular %>TestProbe, <%- entitySingular %>TestProbeApi>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>TestProbe") {} + +const decode<%- entityPlural %> = Schema.decodeUnknownEffect(Schema.Array(<%- entitySingular %>)) +const events = Effect.gen(function* () { + const visible = yield* Ref.make>([]) + const published = yield* PubSub.unbounded() + return { + probe: <%- entitySingular %>TestProbe.of({ events: () => Ref.get(visible) }), + service: Application.<%- entitySingular %>Events.of({ + publish: (event) => Ref.update(visible, (current) => [...current, event]).pipe( + Effect.andThen(PubSub.publish(published, event)), + Effect.asVoid, + ), + }), + stream: Stream.fromPubSub(published), + } +}) + +const repository = (read: () => Effect.EffectModel>, <%- entitySingular %>PersistenceError>, write: (<%- entityPluralIdentifier %>: ReadonlyArray<<%- entitySingular %>Model>) => Effect.EffectPersistenceError>) => + Application.<%- entitySingular %>Repository.of({ + find: (id) => read().pipe(Effect.map((<%- entityPluralIdentifier %>) => <%- entityPluralIdentifier %>.find((<%- entityIdentifier %>) => <%- entityIdentifier %>.id === id))), + list: read, + remove: (id) => Effect.gen(function* () { + const <%- entityPluralIdentifier %> = yield* read() + const <%- entityIdentifier %> = <%- entityPluralIdentifier %>.find((candidate) => candidate.id === id) + if (<%- entityIdentifier %> === undefined) return yield* Effect.fail(new <%- entitySingular %>PersistenceError({ operation: "write" })) + yield* write(<%- entityPluralIdentifier %>.filter((candidate) => candidate.id !== id)) + return <%- entityIdentifier %> + }), + save: (<%- entityIdentifier %>) => read().pipe( + Effect.flatMap((<%- entityPluralIdentifier %>) => write([...<%- entityPluralIdentifier %>.filter((candidate) => candidate.id !== <%- entityIdentifier %>.id), <%- entityIdentifier %>])), + ), + }) + +const fixedIds = (ids: ReadonlyArray) => Effect.gen(function* () { + const remaining = yield* Ref.make(ids) + return Application.<%- entitySingular %>IdGenerator.of({ next: () => Effect.gen(function* () { + const [next, ...rest] = yield* Ref.get(remaining) + if (next === undefined) return yield* Effect.fail(new <%- entitySingular %>IdExhausted()) + yield* Ref.set(remaining, rest) + return yield* Schema.decodeUnknownEffect(<%- entitySingular %>Id)(next).pipe(Effect.mapError(() => new <%- entitySingular %>IdExhausted())) + }) }) +}) + +export const testLayer = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Layer.effectContext(Effect.gen(function* () { + const <%- entityPluralIdentifier %> = yield* Ref.makeModel>>([]) + const eventServices = yield* events + return Context.empty().pipe( + Context.add(Application.<%- entitySingular %>Repository, repository(() => Ref.get(<%- entityPluralIdentifier %>), (next) => Ref.set(<%- entityPluralIdentifier %>, next))), + Context.add(Application.<%- entitySingular %>Clock, Application.<%- entitySingular %>Clock.of({ now: () => Effect.succeed(input.now) })), + Context.add(Application.<%- entitySingular %>IdGenerator, yield* fixedIds(input.ids)), + Context.add(Application.<%- entitySingular %>Events, eventServices.service), + Context.add(<%- entitySingular %>TestProbe, eventServices.probe), + ) +})) + +export const liveLayer = (path: string) => Layer.effectContext(Effect.gen(function* () { + yield* Effect.tryPromise({ try: () => mkdir(dirname(path), { recursive: true }), catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }) }) + const exists = yield* Effect.promise(() => access(path).then(() => true, () => false)) + if (!exists) yield* Effect.tryPromise({ try: () => writeFile(path, "[]\n"), catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }) }) + const read = () => Effect.tryPromise({ try: () => readFile(path, "utf8"), catch: () => new <%- entitySingular %>PersistenceError({ operation: "read" }) }).pipe( + Effect.flatMap((source) => Effect.try({ try: () => JSON.parse(source), catch: () => new <%- entitySingular %>PersistenceError({ operation: "read" }) })), + Effect.flatMap(decode<%- entityPlural %>), + Effect.mapError(() => new <%- entitySingular %>PersistenceError({ operation: "read" })), + ) + const write = (<%- entityPluralIdentifier %>: ReadonlyArray<<%- entitySingular %>Model>) => Effect.tryPromise({ + try: () => writeFile(path, JSON.stringify(<%- entityPluralIdentifier %>, null, 2) + "\n"), + catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }), + }) + const eventServices = yield* events + return Context.empty().pipe( + Context.add(Application.<%- entitySingular %>Repository, repository(read, write)), + Context.add(Application.<%- entitySingular %>Clock, Application.<%- entitySingular %>Clock.of({ now: () => Effect.sync(() => new Date().toISOString()) })), + Context.add(Application.<%- entitySingular %>IdGenerator, Application.<%- entitySingular %>IdGenerator.of({ next: () => Schema.decodeUnknownEffect(<%- entitySingular %>Id)(randomUUID()).pipe(Effect.mapError(() => new <%- entitySingular %>IdExhausted())) })), + Context.add(Application.<%- entitySingular %>Events, eventServices.service), + ) +})) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/tests/__entityId__-runtime.test.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/tests/__entityId__-runtime.test.ts.template new file mode 100644 index 00000000..04f7af48 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/tests/__entityId__-runtime.test.ts.template @@ -0,0 +1,13 @@ +import { expect, it } from "@effect/vitest" +import * as Application from "<%- applicationImport %>" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import { <%- entitySingular %>TestProbe, testLayer } from "../src/index.js" + +it.effect("publishes ordered Test Layer events after writes", () => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provideMerge(testLayer({ ids: ["<%- entityId %>-1"], now: "2026-01-01T00:00:00.000Z" }))) + const services = yield* Effect.all({ app: Application.<%- entitySingular %>Application, probe: <%- entitySingular %>TestProbe }).pipe(Effect.provide(layer)) + const added = yield* services.app.add("write tests") + yield* services.app.complete(added.id) + expect(yield* services.probe.events()).toEqual([{ _tag: "<%- entitySingular %>Added", <%- entityIdentifier %>: added }, { _tag: "<%- entitySingular %>Completed", <%- entityIdentifier %>: { ...added, completed: true } }]) +})) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/package.json.template new file mode 100644 index 00000000..c347ca92 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/package.json.template @@ -0,0 +1,19 @@ +{ + "name": "<%- presentationImport %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "<%- infrastructureImport %>": "workspace:*", + "<%- applicationImport %>": "workspace:*", + "<%- domainImport %>": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..1027f816 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/src/index.ts.template @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Application from "<%- applicationImport %>" +import type { <%- entitySingular %>Event } from "<%- domainImport %>" +import * as Infrastructure from "<%- infrastructureImport %>" + +export const createTestRuntime = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provideMerge(Infrastructure.testLayer(input))) + const services = yield* Effect.all({ application: Application.<%- entitySingular %>Application, probe: Infrastructure.<%- entitySingular %>TestProbe }).pipe(Effect.provide(layer)) + return Object.freeze({ ...services.application, events: services.probe.events }) +}) + +export const createLiveRuntime = (path: string) => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provide(Infrastructure.liveLayer(path))) + return yield* Application.<%- entitySingular %>Application.pipe(Effect.provide(layer)) +}) + +export const renderEvent = (event: <%- entitySingular %>Event): string => + event._tag === "<%- entitySingular %>Added" ? "added:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text + : event._tag === "<%- entitySingular %>Completed" ? "completed:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text + : "removed:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/tests/__entityId__.test.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/tests/__entityId__.test.ts.template new file mode 100644 index 00000000..87127da5 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/tests/__entityId__.test.ts.template @@ -0,0 +1,9 @@ +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import { createTestRuntime } from "../src/index.js" + +it.effect("runs deterministic <%- entitySingular %> behavior", () => Effect.gen(function* () { + const <%- entityIdentifier %> = yield* createTestRuntime({ ids: ["<%- entityId %>-1"], now: "2026-01-01T00:00:00.000Z" }) + expect(yield* <%- entityIdentifier %>.list()).toEqual([]) + expect((yield* <%- entityIdentifier %>.add("write tests")).id).toBe("<%- entityId %>-1") +})) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template new file mode 100644 index 00000000..e053d2e7 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template @@ -0,0 +1,19 @@ +{ + "defaultBase": "HEAD", + "plugins": [ + { + "plugin": "@nx/js/typescript", + "options": { + "typecheck": { + "targetName": "typecheck" + } + } + }, + { + "plugin": "@nx/vitest", + "options": { + "testTargetName": "test" + } + } + ] +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/package.json.template new file mode 100644 index 00000000..24aa68a4 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/package.json.template @@ -0,0 +1,20 @@ +{ + "name": "<%- workspacePackageName %>", + "packageManager": "pnpm@10.14.0", + "private": true, + "scripts": { + "build": "pnpm exec tsc -p tsconfig.build.json", + "test": "pnpm exec vitest run --config vitest.config.mts", + "typecheck": "pnpm exec tsc --noEmit -p tsconfig.build.json" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "@nx/js": "catalog:", + "@nx/vitest": "23.1.0", + "effect": "catalog:", + "nx": "23.1.0", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/pnpm-workspace.yaml.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/pnpm-workspace.yaml.template new file mode 100644 index 00000000..61cde7ea --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/pnpm-workspace.yaml.template @@ -0,0 +1,10 @@ +packages: +<%- workspacePackageGlobs %> + +catalog: + "@effect/vitest": 4.0.0-beta.102 + "@nx/js": 23.1.0 + "@types/node": 20.19.25 + effect: 4.0.0-beta.102 + typescript: 6.0.3 + vitest: 4.1.10 diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/tsconfig.build.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/tsconfig.build.json.template new file mode 100644 index 00000000..2ea2f396 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/tsconfig.build.json.template @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ + "node" + ] + }, + "include": [ +<%- workspaceSourceIncludes %> + ] +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/vitest.config.mts.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/vitest.config.mts.template new file mode 100644 index 00000000..4c1fa7d5 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/vitest.config.mts.template @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: [<%- workspaceTestIncludes %>], + watch: false, + }, +}) diff --git a/packages/app-builder/generation/src/templates/assets/generic/event/__targetRoot__/src/event.ts.template b/packages/app-builder/generation/src/templates/assets/generic/event/__targetRoot__/src/event.ts.template new file mode 100644 index 00000000..9bf225b6 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/event/__targetRoot__/src/event.ts.template @@ -0,0 +1,9 @@ +import * as Data from "effect/Data" +import type { <%- entitySingular %> } from "./model.js" + +export type <%- entitySingular %>Event = Data.TaggedEnum<{ + <%- entitySingular %>Added: { readonly <%- entityIdentifier %>: <%- entitySingular %> } + <%- entitySingular %>Completed: { readonly <%- entityIdentifier %>: <%- entitySingular %> } + <%- entitySingular %>Removed: { readonly <%- entityIdentifier %>: <%- entitySingular %> } +}> +export const <%- entitySingular %>Event = Data.taggedEnum<<%- entitySingular %>Event>() diff --git a/packages/app-builder/generation/src/templates/assets/generic/integration-adapter/__targetRoot__/src/adapter.ts.template b/packages/app-builder/generation/src/templates/assets/generic/integration-adapter/__targetRoot__/src/adapter.ts.template new file mode 100644 index 00000000..ffb63ad5 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/integration-adapter/__targetRoot__/src/adapter.ts.template @@ -0,0 +1,91 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises" +import { randomUUID } from "node:crypto" +import { dirname } from "node:path" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as PubSub from "effect/PubSub" +import * as Ref from "effect/Ref" +import * as Stream from "effect/Stream" +import * as Schema from "effect/Schema" +import * as Application from "<%- applicationImport %>" +import { <%- entitySingular %>, <%- entitySingular %>Id, <%- entitySingular %>IdExhausted, <%- entitySingular %>PersistenceError, type <%- entitySingular %> as Model, type <%- entitySingular %>Event as Event } from "<%- domainImport %>" + +export interface <%- entitySingular %>TestProbeApi { readonly events: () => Effect.Effect> } +export class <%- entitySingular %>TestProbe extends Context.Service<<%- entitySingular %>TestProbe, <%- entitySingular %>TestProbeApi>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>TestProbe") {} + +const decodeValues = Schema.decodeUnknownEffect(Schema.Array(<%- entitySingular %>)) +const events = Effect.gen(function* () { + const visible = yield* Ref.make>([]) + const published = yield* PubSub.unbounded() + return { + probe: <%- entitySingular %>TestProbe.of({ events: () => Ref.get(visible) }), + service: Application.<%- entitySingular %>Events.of({ + publish: (event) => Ref.update(visible, (current) => [...current, event]).pipe( + Effect.andThen(PubSub.publish(published, event)), + Effect.asVoid, + ), + }), + stream: Stream.fromPubSub(published), + } +}) + +const repository = (read: () => Effect.Effect, <%- entitySingular %>PersistenceError>, write: (values: ReadonlyArray) => Effect.EffectPersistenceError>) => + Application.<%- entitySingular %>Repository.of({ + find: (id) => read().pipe(Effect.map((values) => values.find((value) => value.id === id))), + list: read, + remove: (id) => Effect.gen(function* () { + const values = yield* read() + const value = values.find((candidate) => candidate.id === id) + if (value === undefined) return yield* Effect.fail(new <%- entitySingular %>PersistenceError({ operation: "write" })) + yield* write(values.filter((candidate) => candidate.id !== id)) + return value + }), + save: (value) => read().pipe( + Effect.flatMap((values) => write([...values.filter((candidate) => candidate.id !== value.id), value])), + ), + }) + +const fixedIds = (ids: ReadonlyArray) => Effect.gen(function* () { + const remaining = yield* Ref.make(ids) + return Application.<%- entitySingular %>IdGenerator.of({ next: () => Effect.gen(function* () { + const [next, ...rest] = yield* Ref.get(remaining) + if (next === undefined) return yield* Effect.fail(new <%- entitySingular %>IdExhausted()) + yield* Ref.set(remaining, rest) + return yield* Schema.decodeUnknownEffect(<%- entitySingular %>Id)(next).pipe(Effect.mapError(() => new <%- entitySingular %>IdExhausted())) + }) }) +}) + +export const testLayer = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Layer.effectContext(Effect.gen(function* () { + const values = yield* Ref.make>([]) + const eventServices = yield* events + return Context.empty().pipe( + Context.add(Application.<%- entitySingular %>Repository, repository(() => Ref.get(values), (next) => Ref.set(values, next))), + Context.add(Application.<%- entitySingular %>Clock, Application.<%- entitySingular %>Clock.of({ now: () => Effect.succeed(input.now) })), + Context.add(Application.<%- entitySingular %>IdGenerator, yield* fixedIds(input.ids)), + Context.add(Application.<%- entitySingular %>Events, eventServices.service), + Context.add(<%- entitySingular %>TestProbe, eventServices.probe), + ) +})) + +export const liveLayer = (path: string) => Layer.effectContext(Effect.gen(function* () { + yield* Effect.tryPromise({ try: () => mkdir(dirname(path), { recursive: true }), catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }) }) + const exists = yield* Effect.promise(() => access(path).then(() => true, () => false)) + if (!exists) yield* Effect.tryPromise({ try: () => writeFile(path, "[]\n"), catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }) }) + const read = () => Effect.tryPromise({ try: () => readFile(path, "utf8"), catch: () => new <%- entitySingular %>PersistenceError({ operation: "read" }) }).pipe( + Effect.flatMap((source) => Effect.try({ try: () => JSON.parse(source), catch: () => new <%- entitySingular %>PersistenceError({ operation: "read" }) })), + Effect.flatMap(decodeValues), + Effect.mapError(() => new <%- entitySingular %>PersistenceError({ operation: "read" })), + ) + const write = (values: ReadonlyArray) => Effect.tryPromise({ + try: () => writeFile(path, JSON.stringify(values, null, 2) + "\n"), + catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }), + }) + const eventServices = yield* events + return Context.empty().pipe( + Context.add(Application.<%- entitySingular %>Repository, repository(read, write)), + Context.add(Application.<%- entitySingular %>Clock, Application.<%- entitySingular %>Clock.of({ now: () => Effect.sync(() => new Date().toISOString()) })), + Context.add(Application.<%- entitySingular %>IdGenerator, Application.<%- entitySingular %>IdGenerator.of({ next: () => Schema.decodeUnknownEffect(<%- entitySingular %>Id)(randomUUID()).pipe(Effect.mapError(() => new <%- entitySingular %>IdExhausted())) })), + Context.add(Application.<%- entitySingular %>Events, eventServices.service), + ) +})) diff --git a/packages/app-builder/generation/src/templates/assets/generic/interface/__targetRoot__/src/entrypoint.ts.template b/packages/app-builder/generation/src/templates/assets/generic/interface/__targetRoot__/src/entrypoint.ts.template new file mode 100644 index 00000000..21ed912b --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/interface/__targetRoot__/src/entrypoint.ts.template @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Application from "<%- applicationImport %>" +import * as Infrastructure from "<%- infrastructureImport %>" +import type { <%- entitySingular %>Event } from "<%- domainImport %>" + +export const createTestRuntime = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provideMerge(Infrastructure.testLayer(input))) + const services = yield* Effect.all({ application: Application.<%- entitySingular %>Application, probe: Infrastructure.<%- entitySingular %>TestProbe }).pipe(Effect.provide(layer)) + return Object.freeze({ ...services.application, events: services.probe.events }) +}) + +export const createLiveRuntime = (path: string) => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provide(Infrastructure.liveLayer(path))) + return yield* Application.<%- entitySingular %>Application.pipe(Effect.provide(layer)) +}) + +export const renderEvent = (event: <%- entitySingular %>Event): string => + event._tag === "<%- entitySingular %>Added" ? "added:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text + : event._tag === "<%- entitySingular %>Completed" ? "completed:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text + : "removed:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text diff --git a/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/src/model.ts.template b/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/src/model.ts.template new file mode 100644 index 00000000..322d0380 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/src/model.ts.template @@ -0,0 +1,21 @@ +import * as Data from "effect/Data" +import * as Schema from "effect/Schema" + +export const <%- entitySingular %>Id = Schema.NonEmptyString.pipe(Schema.brand("<%- brandNamespace %>.<%- entitySingular %>Id")) +export type <%- entitySingular %>Id = typeof <%- entitySingular %>Id.Type + +export const <%- entitySingular %> = Schema.Struct({ + completed: Schema.Boolean, + createdAt: Schema.String, + id: <%- entitySingular %>Id, + text: Schema.NonEmptyString, +}) +export type <%- entitySingular %> = typeof <%- entitySingular %>.Type + +export class <%- entitySingular %>TextInvalid extends Data.TaggedError("<%- entitySingular %>TextInvalid")<{ readonly reason: "empty" }> {} +export class <%- entitySingular %>NotFound extends Data.TaggedError("<%- entitySingular %>NotFound")<{ readonly id: string }> {} +export class <%- entitySingular %>AlreadyCompleted extends Data.TaggedError("<%- entitySingular %>AlreadyCompleted")<{ readonly id: string }> {} +export class <%- entitySingular %>IdExhausted extends Data.TaggedError("<%- entitySingular %>IdExhausted")<{}> {} +export class <%- entitySingular %>PersistenceError extends Data.TaggedError("<%- entitySingular %>PersistenceError")<{ + readonly operation: "read" | "write" +}> {} diff --git a/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/tests/model.test.ts.template b/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/tests/model.test.ts.template new file mode 100644 index 00000000..03dd4dc2 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/tests/model.test.ts.template @@ -0,0 +1,7 @@ +import { expect, it } from "vitest" +import * as Schema from "effect/Schema" +import { <%- entitySingular %> } from "../src/index.js" + +it("decodes a <%- entitySingular %> schema", () => { + expect(Schema.is(<%- entitySingular %>)({ completed: false, createdAt: "2026-01-01T00:00:00.000Z", id: "<%- entityId %>-1", text: "write tests" })).toBe(true) +}) diff --git a/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/package.json.template new file mode 100644 index 00000000..bad05112 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/package.json.template @@ -0,0 +1,9 @@ +{ + "name": "<%- packageName %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": <%- dependenciesJson %> +} diff --git a/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..c62d67b4 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/src/index.ts.template @@ -0,0 +1,3 @@ +<% JSON.parse(exportsJson).forEach((entry) => { -%> +export { <%- entry.name %> } from "<%- entry.from %>" +<% }) -%> diff --git a/packages/app-builder/generation/src/templates/assets/generic/port/__targetRoot__/src/port.ts.template b/packages/app-builder/generation/src/templates/assets/generic/port/__targetRoot__/src/port.ts.template new file mode 100644 index 00000000..6c3e1eb2 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/port/__targetRoot__/src/port.ts.template @@ -0,0 +1,15 @@ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import { <%- entitySingular %>Id, type <%- entitySingular %>, type <%- entitySingular %>Event as Event, <%- entitySingular %>IdExhausted, <%- entitySingular %>PersistenceError } from "<%- domainImport %>" + +export interface <%- entitySingular %>RepositoryApi { + readonly find: (id: <%- entitySingular %>Id) => Effect.Effect<<%- entitySingular %> | undefined, <%- entitySingular %>PersistenceError> + readonly list: () => Effect.Effect>, <%- entitySingular %>PersistenceError> + readonly remove: (id: <%- entitySingular %>Id) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>PersistenceError> + readonly save: (<%- entityIdentifier %>: <%- entitySingular %>) => Effect.EffectPersistenceError> +} +export class <%- entitySingular %>Repository extends Context.Service<<%- entitySingular %>Repository, <%- entitySingular %>RepositoryApi>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>Repository") {} + +export class <%- entitySingular %>Clock extends Context.Service<<%- entitySingular %>Clock, { readonly now: () => Effect.Effect }>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>Clock") {} +export class <%- entitySingular %>IdGenerator extends Context.Service<<%- entitySingular %>IdGenerator, { readonly next: () => Effect.Effect<<%- entitySingular %>Id, <%- entitySingular %>IdExhausted> }>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>IdGenerator") {} +export class <%- entitySingular %>Events extends Context.Service<<%- entitySingular %>Events, { readonly publish: (event: Event) => Effect.Effect }>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>Events") {} diff --git a/packages/app-builder/generation/src/templates/assets/generic/use-case/__targetRoot__/src/use-case.ts.template b/packages/app-builder/generation/src/templates/assets/generic/use-case/__targetRoot__/src/use-case.ts.template new file mode 100644 index 00000000..bc072033 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/use-case/__targetRoot__/src/use-case.ts.template @@ -0,0 +1,56 @@ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { <%- entitySingular %>Event, <%- entitySingular %>Id, type <%- entitySingular %>, <%- entitySingular %>AlreadyCompleted, <%- entitySingular %>IdExhausted, <%- entitySingular %>NotFound, <%- entitySingular %>PersistenceError, <%- entitySingular %>TextInvalid } from "<%- domainImport %>" +import { <%- entitySingular %>Clock, <%- entitySingular %>Events, <%- entitySingular %>IdGenerator, <%- entitySingular %>Repository } from "./port.js" + +type <%- entitySingular %>Failure = <%- entitySingular %>AlreadyCompleted | <%- entitySingular %>IdExhausted | <%- entitySingular %>NotFound | <%- entitySingular %>PersistenceError | <%- entitySingular %>TextInvalid + +export interface <%- entitySingular %>ApplicationApi { + readonly add: (text: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> + readonly complete: (id: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> + readonly list: () => Effect.Effect>, <%- entitySingular %>PersistenceError> + readonly remove: (id: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> +} +export class <%- entitySingular %>Application extends Context.Service<<%- entitySingular %>Application, <%- entitySingular %>ApplicationApi>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>Application") {} + +const resolveId = (id: string): Effect.Effect<<%- entitySingular %>Id, <%- entitySingular %>NotFound> => + Schema.decodeUnknownEffect(<%- entitySingular %>Id)(id).pipe(Effect.mapError(() => new <%- entitySingular %>NotFound({ id }))) + +export const layer = Layer.effect(<%- entitySingular %>Application, Effect.gen(function* () { + const repository = yield* <%- entitySingular %>Repository + const clock = yield* <%- entitySingular %>Clock + const ids = yield* <%- entitySingular %>IdGenerator + const events = yield* <%- entitySingular %>Events + const find = Effect.fn("<%- entitySingular %>Application.find")(function* (rawId: string) { + const id = yield* resolveId(rawId) + const value = Option.fromUndefinedOr(yield* repository.find(id)) + if (Option.isNone(value)) return yield* Effect.fail(new <%- entitySingular %>NotFound({ id: rawId })) + return value.value + }) + const add = Effect.fn("<%- entitySingular %>Application.add")(function* (text: string) { + const normalized = text.trim() + if (normalized.length === 0) return yield* Effect.fail(new <%- entitySingular %>TextInvalid({ reason: "empty" })) + const value: <%- entitySingular %> = { completed: false, createdAt: yield* clock.now(), id: yield* ids.next(), text: normalized } + yield* repository.save(value) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Added({ <%- entityIdentifier %>: value })) + return value + }) + const complete = Effect.fn("<%- entitySingular %>Application.complete")(function* (id: string) { + const value = yield* find(id) + if (value.completed) return yield* Effect.fail(new <%- entitySingular %>AlreadyCompleted({ id })) + const completed = { ...value, completed: true } + yield* repository.save(completed) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Completed({ <%- entityIdentifier %>: completed })) + return completed + }) + const remove = Effect.fn("<%- entitySingular %>Application.remove")(function* (id: string) { + const value = yield* find(id) + yield* repository.remove(value.id) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Removed({ <%- entityIdentifier %>: value })) + return value + }) + return <%- entitySingular %>Application.of({ add, complete, list: repository.list, remove }) +})) diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/nx.json.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/nx.json.template new file mode 100644 index 00000000..e053d2e7 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/nx.json.template @@ -0,0 +1,19 @@ +{ + "defaultBase": "HEAD", + "plugins": [ + { + "plugin": "@nx/js/typescript", + "options": { + "typecheck": { + "targetName": "typecheck" + } + } + }, + { + "plugin": "@nx/vitest", + "options": { + "testTargetName": "test" + } + } + ] +} diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/package.json.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/package.json.template new file mode 100644 index 00000000..7df46670 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/package.json.template @@ -0,0 +1,17 @@ +{ + "name": "<%- workspacePackageName %>", + "packageManager": "pnpm@10.14.0", + "private": true, + "scripts": { + "build": "pnpm exec tsc -p tsconfig.build.json", + "test": "pnpm exec vitest run --config vitest.config.mts", + "typecheck": "pnpm exec tsc --noEmit -p tsconfig.build.json" + }, + "devDependencies": { + "@nx/js": "23.1.0", + "@nx/vitest": "23.1.0", + "nx": "23.1.0", + "typescript": "6.0.3", + "vitest": "4.1.10" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/pnpm-workspace.yaml.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/pnpm-workspace.yaml.template new file mode 100644 index 00000000..7d26c8c1 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/pnpm-workspace.yaml.template @@ -0,0 +1,2 @@ +packages: +<%- workspacePackageGlobs %> diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/tsconfig.build.json.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/tsconfig.build.json.template new file mode 100644 index 00000000..35cc34f8 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/tsconfig.build.json.template @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "target": "ES2022", + "types": [ + "node" + ] + }, + "include": [ +<%- workspaceSourceIncludes %> + ] +} diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/vitest.config.mts.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/vitest.config.mts.template new file mode 100644 index 00000000..45cef5c1 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/vitest.config.mts.template @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ test: { environment: "node", watch: false } }) diff --git a/packages/app-builder/generation/src/templates/todo/application.ts b/packages/app-builder/generation/src/templates/todo/application.ts deleted file mode 100644 index 5fdcc21f..00000000 --- a/packages/app-builder/generation/src/templates/todo/application.ts +++ /dev/null @@ -1,68 +0,0 @@ -export const applicationTemplate = String.raw`import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as Option from "effect/Option" -import * as Schema from "effect/Schema" -import { TodoEvent, TodoId, type Todo, type TodoEvent as Event, TodoAlreadyCompleted, TodoIdExhausted, TodoNotFound, TodoPersistenceError, TodoTextInvalid } from "../../domain/src/index.js" - -export interface TodoRepositoryApi { - readonly find: (id: TodoId) => Effect.Effect - readonly list: () => Effect.Effect, TodoPersistenceError> - readonly remove: (id: TodoId) => Effect.Effect - readonly save: (todo: Todo) => Effect.Effect -} -export class TodoRepository extends Context.Service()("@effectify/todo/TodoRepository") {} - -export class TodoClock extends Context.Service Effect.Effect }>()("@effectify/todo/TodoClock") {} -export class TodoIdGenerator extends Context.Service Effect.Effect }>()("@effectify/todo/TodoIdGenerator") {} -export class TodoEvents extends Context.Service Effect.Effect }>()("@effectify/todo/TodoEvents") {} - -type TodoFailure = TodoAlreadyCompleted | TodoIdExhausted | TodoNotFound | TodoPersistenceError | TodoTextInvalid - -export interface TodoApplicationApi { - readonly add: (text: string) => Effect.Effect - readonly complete: (id: string) => Effect.Effect - readonly list: () => Effect.Effect, TodoPersistenceError> - readonly remove: (id: string) => Effect.Effect -} -export class TodoApplication extends Context.Service()("@effectify/todo/TodoApplication") {} - -const resolveId = (id: string): Effect.Effect => - Schema.decodeUnknownEffect(TodoId)(id).pipe(Effect.mapError(() => new TodoNotFound({ id }))) - -export const layer = Layer.effect(TodoApplication, Effect.gen(function* () { - const repository = yield* TodoRepository - const clock = yield* TodoClock - const ids = yield* TodoIdGenerator - const events = yield* TodoEvents - const find = Effect.fn("TodoApplication.find")(function* (rawId: string) { - const id = yield* resolveId(rawId) - const todo = Option.fromUndefinedOr(yield* repository.find(id)) - if (Option.isNone(todo)) return yield* Effect.fail(new TodoNotFound({ id: rawId })) - return todo.value - }) - const add = Effect.fn("TodoApplication.add")(function* (text: string) { - const normalized = text.trim() - if (normalized.length === 0) return yield* Effect.fail(new TodoTextInvalid({ reason: "empty" })) - const todo: Todo = { completed: false, createdAt: yield* clock.now(), id: yield* ids.next(), text: normalized } - yield* repository.save(todo) - yield* events.publish(TodoEvent.TodoAdded({ todo })) - return todo - }) - const complete = Effect.fn("TodoApplication.complete")(function* (id: string) { - const todo = yield* find(id) - if (todo.completed) return yield* Effect.fail(new TodoAlreadyCompleted({ id })) - const completed = { ...todo, completed: true } - yield* repository.save(completed) - yield* events.publish(TodoEvent.TodoCompleted({ todo: completed })) - return completed - }) - const remove = Effect.fn("TodoApplication.remove")(function* (id: string) { - const todo = yield* find(id) - yield* repository.remove(todo.id) - yield* events.publish(TodoEvent.TodoRemoved({ todo })) - return todo - }) - return TodoApplication.of({ add, complete, list: repository.list, remove }) -})) -` diff --git a/packages/app-builder/generation/src/templates/todo/cli.ts b/packages/app-builder/generation/src/templates/todo/cli.ts deleted file mode 100644 index 1e539c46..00000000 --- a/packages/app-builder/generation/src/templates/todo/cli.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const cliTemplate = String.raw`import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as Application from "../../../packages/todo/application/src/index.js" -import * as Infrastructure from "../../../packages/todo/infrastructure/src/index.js" -import type { TodoEvent } from "../../../packages/todo/domain/src/index.js" - -export const createTestRuntime = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Effect.gen(function* () { - const layer = Application.layer.pipe(Layer.provideMerge(Infrastructure.testLayer(input))) - const services = yield* Effect.all({ application: Application.TodoApplication, probe: Infrastructure.TodoTestProbe }).pipe(Effect.provide(layer)) - return Object.freeze({ ...services.application, events: services.probe.events }) -}) - -export const createLiveRuntime = (path: string) => Effect.gen(function* () { - const layer = Application.layer.pipe(Layer.provide(Infrastructure.liveLayer(path))) - return yield* Application.TodoApplication.pipe(Effect.provide(layer)) -}) - -export const renderEvent = (event: TodoEvent): string => - event._tag === "TodoAdded" ? "added:" + event.todo.id + ":" + event.todo.text - : event._tag === "TodoCompleted" ? "completed:" + event.todo.id + ":" + event.todo.text - : "removed:" + event.todo.id + ":" + event.todo.text -` diff --git a/packages/app-builder/generation/src/templates/todo/domain.ts b/packages/app-builder/generation/src/templates/todo/domain.ts deleted file mode 100644 index b8a82bab..00000000 --- a/packages/app-builder/generation/src/templates/todo/domain.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const domainTemplate = String.raw`import * as Data from "effect/Data" -import * as Schema from "effect/Schema" - -export const TodoId = Schema.NonEmptyString.pipe(Schema.brand("Effectify.TodoId")) -export type TodoId = typeof TodoId.Type - -export const Todo = Schema.Struct({ - completed: Schema.Boolean, - createdAt: Schema.String, - id: TodoId, - text: Schema.NonEmptyString, -}) -export type Todo = typeof Todo.Type - -export class TodoTextInvalid extends Data.TaggedError("TodoTextInvalid")<{ readonly reason: "empty" }> {} -export class TodoNotFound extends Data.TaggedError("TodoNotFound")<{ readonly id: string }> {} -export class TodoAlreadyCompleted extends Data.TaggedError("TodoAlreadyCompleted")<{ readonly id: string }> {} -export class TodoIdExhausted extends Data.TaggedError("TodoIdExhausted")<{}> {} -export class TodoPersistenceError extends Data.TaggedError("TodoPersistenceError")<{ - readonly operation: "read" | "write" -}> {} - -export type TodoEvent = Data.TaggedEnum<{ - TodoAdded: { readonly todo: Todo } - TodoCompleted: { readonly todo: Todo } - TodoRemoved: { readonly todo: Todo } -}> -export const TodoEvent = Data.taggedEnum() -` diff --git a/packages/app-builder/generation/src/templates/todo/index.ts b/packages/app-builder/generation/src/templates/todo/index.ts deleted file mode 100644 index 08fd65e6..00000000 --- a/packages/app-builder/generation/src/templates/todo/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { applicationTemplate } from "./application.js" -import { cliTemplate } from "./cli.js" -import { domainTemplate } from "./domain.js" -import { infrastructureTemplate } from "./infrastructure.js" - -export interface TodoTemplateFile { - readonly content: string - readonly path: string -} - -const testTemplate = `import { expect, it } from "@effect/vitest"\nimport * as Effect from "effect/Effect"\nimport { createTestRuntime } from "../src/index.js"\n\nit.effect("runs deterministic Todo behavior", () => Effect.gen(function* () {\n const todo = yield* createTestRuntime({ ids: ["todo-1"], now: "2026-01-01T00:00:00.000Z" })\n expect(yield* todo.list()).toEqual([])\n expect((yield* todo.add("write tests")).id).toBe("todo-1")\n}))\n` -const domainTestTemplate = `import { expect, it } from "vitest"\nimport * as Schema from "effect/Schema"\nimport { Todo } from "../src/index.js"\n\nit("decodes a Todo schema", () => {\n expect(Schema.is(Todo)({ completed: false, createdAt: "2026-01-01T00:00:00.000Z", id: "todo-1", text: "write tests" })).toBe(true)\n})\n` -const infrastructureTestTemplate = `import { expect, it } from "@effect/vitest"\nimport * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\nimport * as Application from "../../application/src/index.js"\nimport { TodoTestProbe, testLayer } from "../src/index.js"\n\nit.effect("publishes ordered Test Layer events after writes", () => Effect.gen(function* () {\n const layer = Application.layer.pipe(Layer.provideMerge(testLayer({ ids: ["todo-1"], now: "2026-01-01T00:00:00.000Z" })))\n const services = yield* Effect.all({ app: Application.TodoApplication, probe: TodoTestProbe }).pipe(Effect.provide(layer))\n const added = yield* services.app.add("write tests")\n yield* services.app.complete(added.id)\n expect(yield* services.probe.events()).toEqual([{ _tag: "TodoAdded", todo: added }, { _tag: "TodoCompleted", todo: { ...added, completed: true } }])\n}))\n` - -const files: ReadonlyArray = [ - { content: domainTemplate, path: "packages/todo/domain/src/index.ts" }, - { content: applicationTemplate, path: "packages/todo/application/src/index.ts" }, - { content: infrastructureTemplate, path: "packages/todo/infrastructure/src/index.ts" }, - { content: cliTemplate, path: "apps/todo-cli/src/index.ts" }, - { content: domainTestTemplate, path: "packages/todo/domain/tests/todo.test.ts" }, - { content: infrastructureTestTemplate, path: "packages/todo/infrastructure/tests/todo-runtime.test.ts" }, - { content: testTemplate, path: "apps/todo-cli/tests/todo.test.ts" }, -] - -export const todoTemplateFiles = (): ReadonlyArray => Object.freeze([...files]) - -export const todoTemplateContent = (path: string): string => { - const file = files.find((candidate) => candidate.path === path) - if (file === undefined) throw new Error(`Unknown Todo template: ${path}`) - return file.content -} diff --git a/packages/app-builder/generation/src/templates/todo/infrastructure.ts b/packages/app-builder/generation/src/templates/todo/infrastructure.ts deleted file mode 100644 index 55ae6649..00000000 --- a/packages/app-builder/generation/src/templates/todo/infrastructure.ts +++ /dev/null @@ -1,92 +0,0 @@ -export const infrastructureTemplate = String.raw`import { access, mkdir, readFile, writeFile } from "node:fs/promises" -import { randomUUID } from "node:crypto" -import { dirname } from "node:path" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as PubSub from "effect/PubSub" -import * as Ref from "effect/Ref" -import * as Stream from "effect/Stream" -import * as Schema from "effect/Schema" -import * as Application from "../../application/src/index.js" -import { Todo, TodoId, TodoIdExhausted, TodoPersistenceError, type Todo as TodoModel, type TodoEvent as Event } from "../../domain/src/index.js" - -export interface TodoTestProbeApi { readonly events: () => Effect.Effect> } -export class TodoTestProbe extends Context.Service()("@effectify/todo/TodoTestProbe") {} - -const decodeTodos = Schema.decodeUnknownEffect(Schema.Array(Todo)) -const events = Effect.gen(function* () { - const visible = yield* Ref.make>([]) - const published = yield* PubSub.unbounded() - return { - probe: TodoTestProbe.of({ events: () => Ref.get(visible) }), - service: Application.TodoEvents.of({ - publish: (event) => Ref.update(visible, (current) => [...current, event]).pipe( - Effect.andThen(PubSub.publish(published, event)), - Effect.asVoid, - ), - }), - stream: Stream.fromPubSub(published), - } -}) - -const repository = (read: () => Effect.Effect, TodoPersistenceError>, write: (todos: ReadonlyArray) => Effect.Effect) => - Application.TodoRepository.of({ - find: (id) => read().pipe(Effect.map((todos) => todos.find((todo) => todo.id === id))), - list: read, - remove: (id) => Effect.gen(function* () { - const todos = yield* read() - const todo = todos.find((candidate) => candidate.id === id) - if (todo === undefined) return yield* Effect.fail(new TodoPersistenceError({ operation: "write" })) - yield* write(todos.filter((candidate) => candidate.id !== id)) - return todo - }), - save: (todo) => read().pipe( - Effect.flatMap((todos) => write([...todos.filter((candidate) => candidate.id !== todo.id), todo])), - ), - }) - -const fixedIds = (ids: ReadonlyArray) => Effect.gen(function* () { - const remaining = yield* Ref.make(ids) - return Application.TodoIdGenerator.of({ next: () => Effect.gen(function* () { - const [next, ...rest] = yield* Ref.get(remaining) - if (next === undefined) return yield* Effect.fail(new TodoIdExhausted()) - yield* Ref.set(remaining, rest) - return yield* Schema.decodeUnknownEffect(TodoId)(next).pipe(Effect.mapError(() => new TodoIdExhausted())) - }) }) -}) - -export const testLayer = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Layer.effectContext(Effect.gen(function* () { - const todos = yield* Ref.make>([]) - const eventServices = yield* events - return Context.empty().pipe( - Context.add(Application.TodoRepository, repository(() => Ref.get(todos), (next) => Ref.set(todos, next))), - Context.add(Application.TodoClock, Application.TodoClock.of({ now: () => Effect.succeed(input.now) })), - Context.add(Application.TodoIdGenerator, yield* fixedIds(input.ids)), - Context.add(Application.TodoEvents, eventServices.service), - Context.add(TodoTestProbe, eventServices.probe), - ) -})) - -export const liveLayer = (path: string) => Layer.effectContext(Effect.gen(function* () { - yield* Effect.tryPromise({ try: () => mkdir(dirname(path), { recursive: true }), catch: () => new TodoPersistenceError({ operation: "write" }) }) - const exists = yield* Effect.promise(() => access(path).then(() => true, () => false)) - if (!exists) yield* Effect.tryPromise({ try: () => writeFile(path, "[]\n"), catch: () => new TodoPersistenceError({ operation: "write" }) }) - const read = () => Effect.tryPromise({ try: () => readFile(path, "utf8"), catch: () => new TodoPersistenceError({ operation: "read" }) }).pipe( - Effect.flatMap((source) => Effect.try({ try: () => JSON.parse(source), catch: () => new TodoPersistenceError({ operation: "read" }) })), - Effect.flatMap(decodeTodos), - Effect.mapError(() => new TodoPersistenceError({ operation: "read" })), - ) - const write = (todos: ReadonlyArray) => Effect.tryPromise({ - try: () => writeFile(path, JSON.stringify(todos, null, 2) + "\n"), - catch: () => new TodoPersistenceError({ operation: "write" }), - }) - const eventServices = yield* events - return Context.empty().pipe( - Context.add(Application.TodoRepository, repository(read, write)), - Context.add(Application.TodoClock, Application.TodoClock.of({ now: () => Effect.sync(() => new Date().toISOString()) })), - Context.add(Application.TodoIdGenerator, Application.TodoIdGenerator.of({ next: () => Schema.decodeUnknownEffect(TodoId)(randomUUID()).pipe(Effect.mapError(() => new TodoIdExhausted())) })), - Context.add(Application.TodoEvents, eventServices.service), - ) -})) -` diff --git a/packages/app-builder/generation/src/todo-preset.ts b/packages/app-builder/generation/src/todo-preset.ts index 7d4d0c81..4c054d9a 100644 --- a/packages/app-builder/generation/src/todo-preset.ts +++ b/packages/app-builder/generation/src/todo-preset.ts @@ -1,6 +1,11 @@ import * as Data from "effect/Data" import * as Effect from "effect/Effect" -import { composeTodoAtomic, WorkspaceRootFiles, type GenerationBlockFile } from "./generators/index.js" +import { + composeTodoV1Atomic, + DefaultTodoRenderContext, + WorkspaceRootFiles, + type GenerationBlockFile, +} from "./generators/index.js" import type { TodoPlan } from "./planner.js" export const TodoTopologyRoots = [ @@ -10,36 +15,22 @@ export const TodoTopologyRoots = [ "apps/todo-cli", ] as const -export type TodoTopologyRoot = (typeof TodoTopologyRoots)[number] +export type TodoTopologyRoot = string +export type TodoPackageRole = "application" | "domain" | "infrastructure" | "presentation" export type TodoTopologyFile = GenerationBlockFile /** The canonical workspace surface emitted with the Todo package and application outputs. */ -export const isTodoTopologyPath = (path: string): boolean => - WorkspaceRootFiles.some((file) => file === path) || TodoTopologyRoots.some((root) => path.startsWith(`${root}/`)) +export const isTodoTopologyPath = (path: string, roots: ReadonlyArray = TodoTopologyRoots): boolean => + WorkspaceRootFiles.some((file) => file === path) || roots.some((root) => path.startsWith(`${root}/`)) -/** Fixed render context for the public Todo preset. */ -export const DefaultTodoRenderContext = Object.freeze({ - version: "effectify.render-context/1" as const, - workspace: Object.freeze({ name: "todo-workspace", npmScope: "@effectify" }), - domain: Object.freeze({ id: "domain", importName: "@effectify/todo-domain" }), - entity: Object.freeze({ id: "todo", singular: "Todo", plural: "Todos", importName: "@effectify/todo-cli" }), - packages: Object.freeze([ - Object.freeze({ id: "domain", name: "@effectify/todo-domain", root: "packages/todo/domain" }), - Object.freeze({ id: "application", name: "@effectify/todo-application", root: "packages/todo/application" }), - Object.freeze({ - id: "infrastructure", - name: "@effectify/todo-infrastructure", - root: "packages/todo/infrastructure", - }), - Object.freeze({ id: "presentation", name: "@effectify/todo-cli", root: "apps/todo-cli" }), - ]), -}) +export { DefaultTodoRenderContext } export interface TodoTopologyProject { readonly dependencies: ReadonlyArray readonly name: string readonly root: TodoTopologyRoot + readonly role: TodoPackageRole } export interface TodoTopology { @@ -62,12 +53,6 @@ const requiredCapabilities = [ "todo.events", ] as const -const project = (name: string, root: TodoTopologyRoot, dependencies: ReadonlyArray): TodoTopologyProject => ({ - dependencies: Object.freeze([...dependencies]), - name, - root, -}) - const hasRequiredCapabilities = (plan: TodoPlan): boolean => requiredCapabilities.every((capability) => plan.orderedCapabilities.includes(capability)) @@ -77,30 +62,58 @@ export const createTodoTopology = (plan: TodoPlan): Effect.Effect ({ + id, + name: `${naming.npmScope}/${id === "presentation" ? naming.entrypoint.id : `${naming.domain.id}-${id}`}`, + root: id === "presentation" ? `apps/${naming.entrypoint.id}` : `packages/${naming.domain.id}/${id}`, + })) + const [domain, , , presentation] = packages + const projects = Object.freeze( + packages.map((target, index) => + Object.freeze({ + ...target, + role: target.id, + dependencies: Object.freeze( + packages + .slice(0, index) + .reverse() + .map(({ name }) => name), + ), + }), + ), + ) + const context = { + version: "effectify.render-context/1" as const, + workspace: { name: naming.workspace, npmScope: naming.npmScope }, + domain: { ...naming.domain, importName: domain.name }, + entity: naming.entity, + entrypoint: { ...naming.entrypoint, importName: presentation.name }, + packages, + } - return composeTodoAtomic(DefaultTodoRenderContext).pipe( + return composeTodoV1Atomic(context).pipe( Effect.map(({ contributions }) => Object.freeze({ files: Object.freeze( contributions.map((file) => - Object.freeze({ content: new TextDecoder().decode(file.bytes), owner: file.owner, path: file.path }), + Object.freeze({ + content: new TextDecoder().decode(file.bytes), + owner: file.owner, + path: file.path, + ...(file.template === undefined ? {} : { template: file.template }), + }), ), ), projects, - roots: Object.freeze([...TodoTopologyRoots]), + roots: Object.freeze(packages.map(({ root }) => root)), }), ), Effect.mapError(() => new TodoPresetError({ reason: "atomic-composition" })), diff --git a/packages/app-builder/generation/tests/evolution.test.ts b/packages/app-builder/generation/tests/evolution.test.ts index d04119b9..3d433423 100644 --- a/packages/app-builder/generation/tests/evolution.test.ts +++ b/packages/app-builder/generation/tests/evolution.test.ts @@ -5,7 +5,12 @@ import * as Effect from "effect/Effect" const blockIds = ["model", "use-case", "port", "integration-adapter", "event", "presentation"] as const type BlockId = (typeof blockIds)[number] -type GeneratedFile = { readonly content: string; readonly owner: string; readonly path: string } +type GeneratedFile = { + readonly content: string + readonly owner: string + readonly path: string + readonly template?: unknown +} type Topology = { readonly files: ReadonlyArray readonly projects: ReadonlyArray @@ -35,6 +40,7 @@ type TodoGenerationModule = { readonly bytes: Uint8Array readonly owner: string readonly path: string + readonly template?: unknown }> }, unknown @@ -84,6 +90,7 @@ it.effect("the public Todo topology is exactly the direct atomic catalog output" content: new TextDecoder().decode(file.bytes), owner: file.owner, path: file.path, + template: file.template, })), ) }), diff --git a/packages/app-builder/generation/tests/kernel.test.ts b/packages/app-builder/generation/tests/kernel.test.ts index f5781c57..be34b524 100644 --- a/packages/app-builder/generation/tests/kernel.test.ts +++ b/packages/app-builder/generation/tests/kernel.test.ts @@ -9,8 +9,9 @@ type Generator = import("../src/kernel.js").AtomicGenerator<{ readonly content: const context = { version: "effectify.render-context/1", workspace: { name: "todo", npmScope: "@acme" }, - domain: { id: "todo", importName: "@acme/todo" }, - entity: { id: "todo", singular: "Todo", plural: "Todos", importName: "@acme/todo" }, + domain: { id: "todo", name: "Todo", importName: "@acme/todo" }, + entity: { id: "todo", singular: "Todo", plural: "Todos" }, + entrypoint: { id: "todo", name: "Todo", importName: "@acme/todo" }, packages: [{ id: "domain", name: "@acme/todo", root: "pkg/todo" }], } as const const duplicatePackage = { ...context.packages[0], id: "domain", name: "@acme/app", root: "app" } @@ -63,7 +64,7 @@ it.effect("rejects invalid RenderContext shapes before any generator runs", () = [{ workspace: { ...context.workspace, npmScope: "acme" } }, "schema"], [{ packages: [{ ...context.packages[0], root: "../escape" }] }, "schema"], [{ packages: [...context.packages, duplicatePackage] }, "derived-identity"], - [{ entity: { ...context.entity, importName: "@acme/missing" } }, "derived-identity"], + [{ entrypoint: { ...context.entrypoint, importName: "@acme/missing" } }, "derived-identity"], [{ unexpected: true }, "schema"], ] as const for (const [patch, reason] of invalid) { @@ -98,7 +99,7 @@ it.effect("closes a finite catalog in deterministic dependency order and snapsho ) }), ) -it.effect("fails closed for missing, cyclic, duplicate catalog, owner, and output path identities", () => +it.effect("fails closed for missing, cyclic, duplicate catalog, and output path identities", () => Effect.gen(function* () { const { Catalog, Kernel } = yield* modules() const g = (id: string, requires: ReadonlyArray = [], owner = id, paths?: ReadonlyArray) => @@ -110,15 +111,12 @@ it.effect("fails closed for missing, cyclic, duplicate catalog, owner, and outpu const missing = one(g("application", ["model"])) const cyclic = two(g("application", ["model"]), g("model", ["application"])) const duplicateId = two(g("same"), g("same")) - const duplicateOwner = two(g("first", [], "owner"), g("second", [], "owner")) const repeatedOwner = one(g("same-owner", [], "owner", ["one.ts", "two.ts"])) const duplicatePath = two(g("first", [], "first", ["same.ts"]), g("second", [], "second", ["same.ts"])) const scenarios = [ [missing, ["application"], graph("model", "missing-capability")], [cyclic, ["application"], graph("application", "cyclic-capability")], [duplicateId, ["same"], graph("same", "duplicate-generator-id")], - [duplicateOwner, ["first", "second"], conflict("owner", "duplicate-owner")], - [repeatedOwner, ["same-owner"], conflict("owner", "duplicate-owner")], [duplicatePath, ["first", "second"], conflict("same.ts", "duplicate-path")], ] as const for (const [catalog, selected, [tag, identity, reason]] of scenarios) { @@ -129,5 +127,10 @@ it.effect("fails closed for missing, cyclic, duplicate catalog, owner, and outpu : { _tag: tag, identity, reason }, ) } + const result = yield* compose(Catalog, repeatedOwner, context, [Kernel.identifier("same-owner")]) + expect(result.contributions.map(({ owner, path }) => ({ owner, path }))).toEqual([ + { owner: "owner", path: "one.ts" }, + { owner: "owner", path: "two.ts" }, + ]) }), ) diff --git a/packages/app-builder/generation/tests/planner.test.ts b/packages/app-builder/generation/tests/planner.test.ts index 4e3cfda9..ceed31d6 100644 --- a/packages/app-builder/generation/tests/planner.test.ts +++ b/packages/app-builder/generation/tests/planner.test.ts @@ -17,6 +17,13 @@ const intent = { preset: "todo", capabilities: ["todo.events", "todo.community-labels", "todo.cli-presentation"], } +const naming = { + workspace: "operations-workspace", + npmScope: "@acme", + domain: { id: "operations", name: "Operations" }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "admin-console", name: "AdminConsole" }, +} const modules = () => Effect.all({ @@ -35,15 +42,25 @@ it.effect("S01 returns the canonical Todo envelope and visible plan fixture", () }), ) -it.effect("R01 rejects arbitrary module, template, callback, and command selectors", () => +it.effect("accepts bounded naming authority and rejects unsafe or excess input", () => Effect.gen(function* () { - const { Intent } = yield* modules() + const { Intent, Planner } = yield* modules() + const plan = yield* Planner.planTodo({ ...intent, naming }) + expect(plan.intent.naming).toEqual(naming) + expect(Object.isFrozen(plan.intent.naming?.entity)).toBe(true) for (const selector of ["module", "template", "callback", "command"]) { const failure = yield* Intent.decodeCreationIntent({ ...intent, [selector]: "node:child_process" }).pipe( Effect.flip, ) expect(failure).toMatchObject({ _tag: "InvalidCreationIntent", reason: "schema" }) } + for (const invalid of [ + { ...naming, domain: { ...naming.domain, id: "../operations" } }, + { ...naming, entrypoint: { ...naming.entrypoint, command: "rm -rf /" } }, + ]) { + const failure = yield* Intent.decodeCreationIntent({ ...intent, naming: invalid }).pipe(Effect.flip) + expect(failure).toMatchObject({ _tag: "InvalidCreationIntent", reason: "schema" }) + } }), ) @@ -85,9 +102,10 @@ it.effect("R02 rejects unavailable and incompatible community metadata without l it.effect("S03 and S04 close dependencies in a deterministic order for equivalent selectors", () => Effect.gen(function* () { const { Planner } = yield* modules() - const first = yield* Planner.planTodo(intent) - const second = yield* Planner.planTodo({ ...intent, capabilities: [...intent.capabilities].reverse() }) + const first = yield* Planner.planTodo({ ...intent, naming }) + const second = yield* Planner.planTodo({ ...intent, naming, capabilities: [...intent.capabilities].reverse() }) expect(first).toEqual(second) + expect(first.intent.naming).toEqual(naming) expect(first.orderedCapabilities).toEqual(JSON.parse(fixture).orderedCapabilities) }), ) diff --git a/packages/app-builder/generation/tests/surface-request.ts b/packages/app-builder/generation/tests/surface-request.ts index c679b0e6..96151066 100644 --- a/packages/app-builder/generation/tests/surface-request.ts +++ b/packages/app-builder/generation/tests/surface-request.ts @@ -5,8 +5,9 @@ export const surfaceRequest = (Generation: GenerationModule, scope: string, work context: { version: "effectify.render-context/1" as const, workspace: { name: workspace, npmScope: scope }, - domain: { id: "domain", importName: `${scope}/task-core` }, - entity: { id: "task", singular: "Task", plural: "Tasks", importName: `${scope}/task-app` }, + domain: { id: "task", name: "Task", importName: `${scope}/task-core` }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "task-app", name: "TaskApp", importName: `${scope}/task-app` }, packages: [ { id: "domain", name: `${scope}/task-core`, root: "packages/task/core" }, { id: "application", name: `${scope}/task-app`, root: "apps/task-app" }, diff --git a/packages/app-builder/generation/tests/surfaces.test.ts b/packages/app-builder/generation/tests/surfaces.test.ts index aa1c7fe5..61636047 100644 --- a/packages/app-builder/generation/tests/surfaces.test.ts +++ b/packages/app-builder/generation/tests/surfaces.test.ts @@ -25,8 +25,9 @@ const context = ( ) => ({ version: "effectify.render-context/1" as const, workspace: { name: workspace, npmScope: scope }, - domain: { id: "domain", importName: `${scope}/task-core` }, - entity: { id: "task", singular: "Task", plural: "Tasks", importName: `${scope}/task-app` }, + domain: { id: "task", name: "Task", importName: `${scope}/task-core` }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "task-app", name: "TaskApp", importName: `${scope}/task-app` }, packages: [ { id: "domain", name: `${scope}/task-core`, root: roots.domain }, { id: "application", name: `${scope}/task-app`, root: roots.application }, diff --git a/packages/app-builder/generation/tests/todo-atomic.test.ts b/packages/app-builder/generation/tests/todo-atomic.test.ts index 7a71bf10..d0991676 100644 --- a/packages/app-builder/generation/tests/todo-atomic.test.ts +++ b/packages/app-builder/generation/tests/todo-atomic.test.ts @@ -10,8 +10,9 @@ type AtomicTodoModule = typeof import("../src/generators/index.js") const context = () => ({ version: "effectify.render-context/1" as const, workspace: { name: "task-workspace", npmScope: "@acme" }, - domain: { id: "domain", importName: "@acme/task-domain" }, - entity: { id: "task", singular: "Task", plural: "Tasks", importName: "@acme/task-cli" }, + domain: { id: "task", name: "Task", importName: "@acme/task-domain" }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "task-cli", name: "TaskCli", importName: "@acme/task-cli" }, packages: [ { id: "domain", name: "@acme/task-domain", root: "modules/task-core" }, { id: "application", name: "@acme/task-application", root: "modules/task-service" }, @@ -87,7 +88,7 @@ it.effect("each Todo capability closes only its real implementation prerequisite new TextDecoder().decode( port.contributions.find((file) => file.path === "modules/task-core/src/index.ts")?.bytes, ), - ).toContain('export { TodoEvent } from "./event.js"') + ).toContain('export { TaskEvent } from "./event.js"') const missing = yield* Todo.composeTodoAtomic( { ...context(), packages: context().packages.filter((target) => target.id !== "application") }, ["model"], diff --git a/packages/app-builder/generation/tests/todo-v1.test.ts b/packages/app-builder/generation/tests/todo-v1.test.ts index 634454fd..bb1671f2 100644 --- a/packages/app-builder/generation/tests/todo-v1.test.ts +++ b/packages/app-builder/generation/tests/todo-v1.test.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect" import * as Planner from "../src/planner.js" import * as TodoGeneration from "../src/generators/index.js" import * as TodoPreset from "../src/todo-preset.js" -import { migrateTodoV1Owners } from "../src/todo-v1.js" import { TodoV1Fixture } from "./fixtures/todo-v1.js" const intent = { capabilities: ["todo.events"], preset: "todo", version: "effectify.creation-intent/1" } const expectedOutputs = TodoV1Fixture.map(([path, digest]) => [path, digest]) @@ -13,11 +12,16 @@ const digests = (files: ReadonlyArray<{ readonly content: string; readonly path: files .map((file) => [file.path, createHash("sha256").update(file.content).digest("hex")]) .sort(([left], [right]) => left.localeCompare(right)) -const defaultContext = () => structuredClone(TodoPreset.DefaultTodoRenderContext) -const withPackageName = (context: ReturnType, id: string, name: string) => ({ - ...context, - packages: context.packages.map((target) => (target.id === id ? { ...target, name } : target)), -}) +const customIntent = { + ...intent, + naming: { + workspace: "operations-workspace", + npmScope: "@acme", + domain: { id: "operations", name: "Operations" }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "admin-console", name: "AdminConsole" }, + }, +} it.effect("freezes the exact eighteen Todo v1 bytes for public and direct atomic defaults", () => Effect.gen(function* () { const plan = yield* Planner.planTodo(intent) @@ -33,52 +37,31 @@ it.effect("freezes the exact eighteen Todo v1 bytes for public and direct atomic expect(direct.contributions.map(({ owner, path }) => [path, owner])).toEqual(expectedOwners) }), ) -it.effect("migrates only every exact legacy Todo owner to its canonical atomic or surface owner", () => +it.effect("renders independent names through the public intent, planner, and topology route", () => Effect.gen(function* () { - const legacy = TodoV1Fixture.map(([path, _digest, owner]) => ({ owner, path })) - const replaceFirstOwner = (owner: string) => [{ ...legacy[0], owner }, ...legacy.slice(1)] - const migrated = yield* migrateTodoV1Owners(legacy) - expect(migrated).toEqual(TodoV1Fixture.map(([path, _digest, _legacy, owner]) => ({ owner, path }))) - const hostile = [ - [legacy.slice(1), "missing-output"], - [replaceFirstOwner("todo-model-spoof"), "unknown-owner"], - [replaceFirstOwner("package-surface"), "ambiguous-owner"], - [[...legacy, legacy[0]], "duplicate-path"], - [replaceFirstOwner(legacy[1].owner), "mismatched-path"], - [replaceFirstOwner(TodoV1Fixture[0][3]), "already-invalid-owner"], - ] as const - for (const [identities, reason] of hostile) { - const failure = yield* migrateTodoV1Owners(identities).pipe(Effect.flip) - expect(failure).toMatchObject({ _tag: "TodoV1OwnerMigrationError", reason }) - } - }), -) -it.effect("uses Unit4 atomics for any context that differs from the complete Todo v1 default", () => - Effect.gen(function* () { - const base = defaultContext() - const renamedDomain = withPackageName(base, "domain", "@effectify/next-domain") - const changedImport = { - ...renamedDomain, - domain: { ...renamedDomain.domain, importName: "@effectify/next-domain" }, - } - const changedEntity = { ...base, entity: { ...base.entity, id: "task" } } - const changedDomain = { ...base, domain: { ...base.domain, id: "tasks" } } - for (const context of [changedImport, changedDomain, changedEntity]) { - const generic = yield* TodoGeneration.composeTodoAtomic(context) - expect(generic.generatorIds).toContain("workspace-surface") - expect(generic.generatorIds).not.toContain("todo-v1-workspace") - expect(generic.contributions.map(({ owner, path }) => [path, owner])).not.toEqual(expectedOwners) - } - const parameterized = yield* TodoGeneration.composeTodoAtomic(changedImport) - const packageManifest = parameterized.contributions.find( - (file) => file.path === "packages/todo/domain/package.json", + const topology = yield* TodoPreset.createTodoTopology(yield* Planner.planTodo(customIntent)) + expect(topology.roots).toEqual([ + "packages/operations/domain", + "packages/operations/application", + "packages/operations/infrastructure", + "apps/admin-console", + ]) + expect(topology.projects.map(({ name }) => name)).toEqual([ + "@acme/operations-domain", + "@acme/operations-application", + "@acme/operations-infrastructure", + "@acme/admin-console", + ]) + expect(topology.files.map(({ path }) => path)).toEqual( + expect.arrayContaining([ + "packages/operations/domain/tests/task.test.ts", + "packages/operations/infrastructure/tests/task-runtime.test.ts", + "apps/admin-console/tests/task.test.ts", + ]), ) - expect(new TextDecoder().decode(packageManifest?.bytes)).toContain("@effectify/next-domain") - const extraPackage = { - ...base, - packages: [...base.packages, { id: "extra", name: "@effectify/todo-extra", root: "packages/todo/extra" }], - } - const failure = yield* TodoGeneration.composeTodoAtomic(extraPackage).pipe(Effect.flip) - expect(failure).toMatchObject({ _tag: "RenderFailure", generatorId: "todo-surface-input", reason: "unsafe-path" }) + const source = topology.files.map(({ content }) => content).join("\n") + expect(source).toContain("TaskApplication") + expect(source).toContain("@acme/operations-domain") + expect(source).not.toMatch(/Todo|Effectify|@effectify/) }), ) diff --git a/packages/app-builder/nx-plugin/src/apply-plan.ts b/packages/app-builder/nx-plugin/src/apply-plan.ts index dde55dd1..770a3718 100644 --- a/packages/app-builder/nx-plugin/src/apply-plan.ts +++ b/packages/app-builder/nx-plugin/src/apply-plan.ts @@ -1,5 +1,6 @@ -import type { Tree } from "@nx/devkit" -import { TodoPreset, type TodoPlan } from "@effectify/app-builder-generation" +import { generateFiles, type Tree } from "@nx/devkit" +import { Templates, TodoPreset, type TodoPlan } from "@effectify/app-builder-generation" +import { Buffer } from "node:buffer" import * as Data from "effect/Data" import * as Effect from "effect/Effect" @@ -14,29 +15,24 @@ export class TodoTopologyApplyError extends Data.TaggedError("TodoTopologyApplyE readonly reason: "dependency-direction" | "ownership-conflict" }> {} -const outwardProjects = new Set(["@effectify/todo-infrastructure", "@effectify/todo-cli"]) +interface StagedOutput { + readonly bytes: Buffer + readonly path: string +} -const isAllowedPath = (path: string): boolean => +const isAllowedPath = (path: string, roots: ReadonlyArray): boolean => path.length > 0 && !path.includes("\\") && path .split("/") .every((segment) => segment !== "." && segment !== ".." && /^[A-Za-z0-9.][A-Za-z0-9._-]*$/.test(segment)) && - TodoPreset.isTodoTopologyPath(path) - -const validateOwnership = (tree: Tree, topology: TodoPreset.TodoTopology): TodoTopologyApplyError | undefined => { - const filesByPath = new Map() - - for (const file of topology.files) { - const existing = filesByPath.get(file.path) - if (!isAllowedPath(file.path) || existing !== undefined) { - return new TodoTopologyApplyError({ path: file.path, reason: "ownership-conflict" }) - } - filesByPath.set(file.path, file) + TodoPreset.isTodoTopologyPath(path, roots) - const current = tree.read(file.path, "utf8") - if (current !== null && current !== file.content) { - return new TodoTopologyApplyError({ path: file.path, reason: "ownership-conflict" }) +const validateOwnership = (tree: Tree, outputs: ReadonlyArray): TodoTopologyApplyError | undefined => { + for (const output of outputs) { + const current = tree.read(output.path) + if (current !== null && !current.equals(output.bytes)) { + return new TodoTopologyApplyError({ path: output.path, reason: "ownership-conflict" }) } } @@ -44,9 +40,14 @@ const validateOwnership = (tree: Tree, topology: TodoPreset.TodoTopology): TodoT } const validateDependencies = (topology: TodoPreset.TodoTopology): TodoTopologyApplyError | undefined => { + const outwardNames = new Set( + topology.projects + .filter(({ role }) => role === "infrastructure" || role === "presentation") + .map(({ name }) => name), + ) for (const project of topology.projects) { - const isInwardOnly = project.name === "@effectify/todo-domain" || project.name === "@effectify/todo-application" - if (isInwardOnly && project.dependencies.some((dependency: string) => outwardProjects.has(dependency))) { + const isInwardOnly = project.role === "domain" || project.role === "application" + if (isInwardOnly && project.dependencies.some((dependency: string) => outwardNames.has(dependency))) { return new TodoTopologyApplyError({ project: project.name, reason: "dependency-direction" }) } } @@ -54,24 +55,84 @@ const validateDependencies = (topology: TodoPreset.TodoTopology): TodoTopologyAp return undefined } +const stageTemplates = ( + tree: Tree, + topology: TodoPreset.TodoTopology, +): ReadonlyArray | TodoTopologyApplyError => { + const paths = new Set() + for (const file of topology.files) { + if (!isAllowedPath(file.path, topology.roots) || file.owner.length === 0 || paths.has(file.path)) { + return new TodoTopologyApplyError({ path: file.path, reason: "ownership-conflict" }) + } + paths.add(file.path) + } + const staged = new Map() + const staging = new Proxy(tree, { + get: (target, property, receiver) => + property === "exists" + ? (path: string) => staged.has(path) + : property === "write" + ? (path: string, content: Buffer | string) => { + if (paths.has(path)) staged.set(path, Buffer.from(content)) + } + : Reflect.get(target, property, receiver), + }) + try { + for (const file of topology.files) if (file.template === undefined) staged.set(file.path, Buffer.from(file.content)) + for (const group of Templates.templateGroups(topology.files)) { + generateFiles(staging, Templates.templateDirectory(group), "", group.substitutions) + } + } catch { + return new TodoTopologyApplyError({ reason: "ownership-conflict" }) + } + if (staged.size !== paths.size) { + return new TodoTopologyApplyError({ reason: "ownership-conflict" }) + } + const outputs: Array = [] + for (const path of paths) { + const bytes = staged.get(path) + if (bytes === undefined) { + return new TodoTopologyApplyError({ path, reason: "ownership-conflict" }) + } + outputs.push(Object.freeze({ bytes, path })) + } + return Object.freeze(outputs) +} + /** Applies a fully validated topology in one Tree-only mutation boundary. */ export const applyTodoTopology = ( tree: Tree, topology: TodoPreset.TodoTopology, ): Effect.Effect => Effect.gen(function* () { - const failure = validateDependencies(topology) ?? validateOwnership(tree, topology) + const dependencyFailure = validateDependencies(topology) + if (dependencyFailure !== undefined) return yield* Effect.fail(dependencyFailure) + const stagedResult = stageTemplates(tree, topology) + if (stagedResult instanceof TodoTopologyApplyError) return yield* Effect.fail(stagedResult) + const staged = stagedResult + const failure = validateOwnership(tree, staged) if (failure !== undefined) { return yield* Effect.fail(failure) } - const writtenPaths: Array = [] - for (const file of topology.files) { - if (tree.read(file.path, "utf8") === null) { - tree.write(file.path, file.content) - writtenPaths.push(file.path) - } - } + const writtenPaths = yield* Effect.try({ + try: () => { + const written: Array = [] + try { + for (const output of staged) { + if (tree.read(output.path, "utf8") === null) { + tree.write(output.path, output.bytes) + written.push(output.path) + } + } + return written + } catch (error) { + for (const path of written.reverse()) tree.delete(path) + throw error + } + }, + catch: () => new TodoTopologyApplyError({ reason: "ownership-conflict" }), + }) return Object.freeze({ generatedRoots: Object.freeze([...topology.roots]), diff --git a/packages/app-builder/nx-plugin/tests/apply-plan.test.ts b/packages/app-builder/nx-plugin/tests/apply-plan.test.ts index a8fa80fd..567abd86 100644 --- a/packages/app-builder/nx-plugin/tests/apply-plan.test.ts +++ b/packages/app-builder/nx-plugin/tests/apply-plan.test.ts @@ -1,8 +1,19 @@ import { createTree } from "@nx/devkit/testing" import { expect, it } from "@effect/vitest" import * as Effect from "effect/Effect" +import { vi } from "vitest" import { surfaceRequest } from "../../generation/tests/surface-request.js" +vi.mock("@nx/devkit", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + generateFiles: (...args: Parameters) => { + actual.generateFiles(...args) + }, + } +}) + type ApplyPlanModule = typeof import("../src/apply-plan.js") type PublicModule = typeof import("../src/index.js") type GenerationModule = typeof import("@effectify/app-builder-generation") @@ -71,6 +82,31 @@ it.effect("S05 and R06 apply an approved Todo plan through Tree with exactly fou }), ) +it.effect("materializes custom public naming through real Nx generateFiles", () => { + const intent = { + version: "effectify.creation-intent/1", + preset: "todo", + capabilities: ["todo.events"], + naming: { + workspace: "operations-workspace", + npmScope: "@acme", + domain: { id: "operations", name: "Operations" }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "admin-console", name: "AdminConsole" }, + }, + } + return Effect.gen(function* () { + const { ApplyPlan, Planner, TodoPreset } = yield* modules() + const plan = yield* Planner.planTodo(intent) + const topology = yield* TodoPreset.createTodoTopology(plan) + const tree = createTree() + const result = yield* ApplyPlan.applyTodoPlan(tree, plan) + expect(result.writtenPaths).toEqual(topology.files.map(({ path }) => path)) + expect(tree.read("apps/admin-console/src/index.ts", "utf8")).toContain("TaskApplication") + expect(tree.read("packages/operations/domain/package.json", "utf8")).toContain("@acme/operations-domain") + }) +}) + it.effect("accepts only canonical workspace-root files before mutating the Tree", () => Effect.gen(function* () { const { ApplyPlan, Planner, TodoPreset } = yield* modules() diff --git a/packages/app-builder/nx-plugin/tsconfig.lib.json b/packages/app-builder/nx-plugin/tsconfig.lib.json index 724cdca8..993b2290 100644 --- a/packages/app-builder/nx-plugin/tsconfig.lib.json +++ b/packages/app-builder/nx-plugin/tsconfig.lib.json @@ -5,7 +5,7 @@ "rootDir": "./src", "lib": ["ES2022"], "target": "ES2022", - "types": [] + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 176a1f5a..34b3c199 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -865,6 +865,9 @@ importers: effect: specifier: 'catalog:' version: 4.0.0-beta.102 + ejs: + specifier: 5.0.1 + version: 5.0.1 tslib: specifier: 'catalog:' version: 2.8.1 @@ -875,6 +878,9 @@ importers: '@nx/devkit': specifier: 23.1.0 version: 23.1.0(nx@23.1.0(@swc-node/register@1.11.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@swc/core@1.15.43(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43(@swc/helpers@0.5.23))) + '@types/ejs': + specifier: 3.1.5 + version: 3.1.5 '@types/node': specifier: 'catalog:' version: 20.19.25 @@ -6339,6 +6345,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/ejs@3.1.5': + resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -18526,6 +18535,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/ejs@3.1.5': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1