diff --git a/packages/app-builder/cli/package.json b/packages/app-builder/cli/package.json index 38d63e89..e2c616e4 100644 --- a/packages/app-builder/cli/package.json +++ b/packages/app-builder/cli/package.json @@ -2,6 +2,9 @@ "name": "@effectify/app-builder-cli", "version": "0.0.0", "type": "module", + "bin": { + "effectify-app-builder": "./dist/src/main.js" + }, "dependencies": { "@effectify/app-builder-generation": "workspace:*", "tslib": "catalog:" 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/src/main.ts b/packages/app-builder/cli/src/main.ts index 319923fe..f7d6c219 100644 --- a/packages/app-builder/cli/src/main.ts +++ b/packages/app-builder/cli/src/main.ts @@ -1,4 +1,8 @@ +#!/usr/bin/env node + +import { realpathSync } from "node:fs" import { readFile } from "node:fs/promises" +import { pathToFileURL } from "node:url" import * as Effect from "effect/Effect" import { type CliCommand, @@ -171,3 +175,7 @@ export const runNodeCli = (args: ReadonlyArray = process.argv.slice(2)): process.exitCode = code return code }) + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + await runNodeCli() +} 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/e2e/project.json b/packages/app-builder/e2e/project.json index 12ad46fc..d26f4bda 100644 --- a/packages/app-builder/e2e/project.json +++ b/packages/app-builder/e2e/project.json @@ -21,6 +21,12 @@ }, "test": { "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@effectify/app-builder-cli"], + "target": "build" + } + ], "options": { "command": "../../../node_modules/.bin/vitest run --config vitest.config.mts", "cwd": "packages/app-builder/e2e" diff --git a/packages/app-builder/e2e/src/golden-workspace.ts b/packages/app-builder/e2e/src/golden-workspace.ts index 5beb12ce..766e5885 100644 --- a/packages/app-builder/e2e/src/golden-workspace.ts +++ b/packages/app-builder/e2e/src/golden-workspace.ts @@ -1,15 +1,22 @@ import { spawn, type ChildProcess } from "node:child_process" -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises" +import { appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import { createRequire } from "node:module" import { createServer } from "node:net" import { tmpdir } from "node:os" -import { dirname, join } from "node:path" +import { basename, dirname, join } from "node:path" import { fileURLToPath } from "node:url" -import * as Effect from "effect/Effect" 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" }, + }, } const commandTimeout = 90_000 @@ -22,6 +29,7 @@ export interface CommandEvidence { readonly argv: ReadonlyArray readonly exitCode: number readonly label: string + readonly output?: string } export interface GoldenWorkspaceEvidence { @@ -37,16 +45,29 @@ export interface GoldenWorkspaceEvidence { readonly secondWritePaths: ReadonlyArray } readonly rootProjectNames: ReadonlyArray - readonly todo: { - readonly events: ReadonlyArray - readonly state: "[]\n" - } + readonly task: { readonly events: ReadonlyArray; readonly id: string; readonly state: "[]\n" } + readonly termination?: TerminationEvidence } interface ProcessResult { readonly exitCode: number readonly stderr: string readonly stdout: string + readonly termination?: TerminationEvidence +} + +interface TerminationEvidence { + readonly descendantPid?: number + readonly descendantGone: boolean + readonly directChildClosed: boolean + readonly directChildClosedBeforeKill: boolean + readonly escalated: boolean + readonly groupId: number + readonly groupGone: boolean + readonly killSent: boolean + readonly readinessObserved: boolean + readonly stopped: boolean + readonly termSent: boolean } interface WorkspaceState { @@ -54,28 +75,120 @@ interface WorkspaceState { readonly store: string } -interface GeneratedFile { - readonly content: string - readonly path: string +const exists = async (path: string): Promise => + stat(path) + .then(() => true) + .catch(() => false) + +const sleep = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)) + +class ProcessTimeoutError extends Error {} + +const waitForClose = (closed: Promise, timeout: number): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + clearTimeout(timer) + reject(new ProcessTimeoutError(`Process did not close within ${timeout}ms`)) + }, timeout) + closed.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error: unknown) => { + clearTimeout(timer) + reject(error) + }, + ) + }) + +const isAbsent = (error: unknown): boolean => + typeof error === "object" && error !== null && Reflect.get(error, "code") === "ESRCH" + +const signalGroup = (pgid: number, signal: NodeJS.Signals): boolean => { + try { + process.kill(-pgid, signal) + return true + } catch (error) { + if (isAbsent(error)) return false + throw error + } } -interface GenerationModule { - readonly Planner: { - readonly planTodo: (input: unknown) => Effect.Effect +const processExists = (pid: number): boolean => { + try { + process.kill(pid, 0) + return true + } catch (error) { + if (isAbsent(error)) return false + if (typeof error === "object" && error !== null && Reflect.get(error, "code") === "EPERM") return true + throw error } - readonly TodoPreset: { - readonly createTodoTopology: ( - plan: unknown, - ) => Effect.Effect<{ readonly files: ReadonlyArray }, unknown> +} + +const waitUntil = async (predicate: () => boolean, timeout: number): Promise => { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() >= deadline) return false + await sleep(25) } + return true } -const exists = async (path: string): Promise => - stat(path) - .then(() => true) - .catch(() => false) +const waitForReadiness = async (path: string, assertRunning: () => void): Promise => { + const deadline = Date.now() + commandTimeout + while (Date.now() < deadline) { + assertRunning() + try { + const value = JSON.parse(await readFile(path, "utf8")) as { readonly descendantPid?: unknown } + if (typeof value.descendantPid === "number" && value.descendantPid > 0) return value.descendantPid + } catch { + // The generated Vitest test has not completed its descendant handshake yet. + } + await sleep(25) + } + throw new Error("Generated Vitest workflow did not publish deterministic readiness evidence") +} -const sleep = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)) +const terminateGroup = async ( + pgid: number, + directClose: Promise, + isDirectChildClosed: () => boolean, + readinessObserved: boolean, + descendantPid?: number, +): Promise => { + const termSent = signalGroup(pgid, "SIGTERM") + let groupGone = await waitUntil(() => !processExists(-pgid), shutdownTimeout) + const escalated = !groupGone + const directChildClosedBeforeKill = isDirectChildClosed() + let killSent = false + if (escalated) { + killSent = signalGroup(pgid, "SIGKILL") + groupGone = await waitUntil(() => !processExists(-pgid), shutdownTimeout) + } + const directChildClosed = await waitForClose(directClose, shutdownTimeout).then( + () => true, + (error) => { + if (error instanceof ProcessTimeoutError) return false + throw error + }, + ) + const descendantGone = + descendantPid === undefined || (await waitUntil(() => !processExists(descendantPid), shutdownTimeout)) + return { + descendantPid, + descendantGone, + directChildClosed, + directChildClosedBeforeKill, + escalated, + groupId: pgid, + groupGone, + killSent, + readinessObserved, + stopped: directChildClosed && groupGone && descendantGone, + termSent, + } +} const environmentFor = (workspace: string): NodeJS.ProcessEnv => ({ CI: "true", @@ -88,55 +201,98 @@ const environmentFor = (workspace: string): NodeJS.ProcessEnv => ({ PNPM_HOME: join(workspace, "pnpm-home"), }) -const run = ( +const run = async ( cwd: string, argv: ReadonlyArray, environment: NodeJS.ProcessEnv, timeout = commandTimeout, -): Promise => - new Promise((resolve, reject) => { - const [command, ...args] = argv - if (command === undefined) { - reject(new Error("E2E subprocess requires an executable")) - return - } - const child = spawn(command, args, { cwd, env: environment, shell: false, stdio: ["ignore", "pipe", "pipe"] }) - let stdout = "" - let stderr = "" - let timedOut = false - const timer = setTimeout(() => { - timedOut = true - child.kill("SIGTERM") - setTimeout(() => child.kill("SIGKILL"), shutdownTimeout).unref() - }, timeout) - child.stdout.setEncoding("utf8") - child.stderr.setEncoding("utf8") - child.stdout.on("data", (chunk: string) => { - stdout += chunk - }) - child.stderr.on("data", (chunk: string) => { - stderr += chunk - }) + input = "", + readinessPath?: string, +): Promise => { + const [command, ...args] = argv + if (command === undefined) throw new Error("E2E subprocess requires an executable") + const child = spawn(command, args, { + cwd, + detached: true, + env: environment, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }) + let directChildClosed = false + let childError: unknown + const closed = new Promise((resolve, reject) => { child.once("error", (error) => { - clearTimeout(timer) + childError = error reject(error) }) child.once("close", (code) => { - clearTimeout(timer) - resolve({ exitCode: timedOut ? 130 : (code ?? 1), stderr, stdout }) + directChildClosed = true + resolve(code) }) }) + void closed.catch(() => undefined) + if (child.pid === undefined) { + await closed + throw new Error("E2E subprocess did not expose a process-group identifier") + } + const pgid = child.pid + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk: string) => { + stdout += chunk + }) + child.stderr.on("data", (chunk: string) => { + stderr += chunk + }) + child.stdin.end(input) + + if (readinessPath !== undefined) { + let descendantPid: number + try { + descendantPid = await waitForReadiness(readinessPath, () => { + if (childError !== undefined) throw childError + if (directChildClosed) throw new Error("Generated Vitest workflow closed before publishing readiness evidence") + }) + } catch (error) { + const cleanup = await terminateGroup(pgid, closed, () => directChildClosed, false) + if (!cleanup.stopped) + throw new Error(`Unready generated workflow process group did not stop: ${JSON.stringify(cleanup)}`) + throw error + } + const termination = await terminateGroup(pgid, closed, () => directChildClosed, true, descendantPid) + if (!termination.stopped) + throw new Error(`Generated workflow process group did not stop: ${JSON.stringify(termination)}`) + return { exitCode: 130, stderr, stdout, termination } + } + + try { + const completed = await waitForClose(closed, timeout) + return { exitCode: completed ?? 1, stderr, stdout } + } catch (error) { + const termination = await terminateGroup(pgid, closed, () => directChildClosed, false) + if (!termination.stopped) throw new Error(`Failed process group did not stop: ${JSON.stringify(termination)}`) + throw error + } +} + +export const runGoldenCommandTimeout = (timeout = 50): Promise => + run( + tmpdir(), + [process.execPath, "-e", 'require("node:net").createServer().listen(0, "127.0.0.1")'], + process.env, + timeout, + ) const stop = async (child: ChildProcess): Promise => { - if (child.exitCode !== null) return true - child.kill("SIGTERM") - const stopped = await Promise.race([ - new Promise((resolve) => child.once("close", () => resolve(true))), - sleep(shutdownTimeout).then(() => false), - ]) - if (stopped) return true - child.kill("SIGKILL") - return new Promise((resolve) => child.once("close", () => resolve(true))) + if (child.pid === undefined) return true + const pgid = child.pid + if (!processExists(-pgid)) return true + signalGroup(pgid, "SIGTERM") + if (await waitUntil(() => !processExists(-pgid), shutdownTimeout)) return true + signalGroup(pgid, "SIGKILL") + return waitUntil(() => !processExists(-pgid), shutdownTimeout) } const freePort = (): Promise => @@ -171,96 +327,68 @@ const write = async (path: string, content: string): Promise => { await writeFile(path, content) } -const generation = () => - Effect.promise(() => import(new URL("../../generation/src/index.js", import.meta.url).href)) - -const project = (name: string, root: string) => - `${JSON.stringify({ name, projectType: root.startsWith("apps/") ? "application" : "library", root }, null, 2)}\n` - -const workspaceFiles = (): Readonly> => ({ - "nx.json": `${JSON.stringify({ defaultBase: "HEAD", plugins: [] }, null, 2)}\n`, - "package.json": `${JSON.stringify( - { - name: "effectify-todo-nested-e2e", - packageManager: "pnpm@10.14.0", - private: true, - devDependencies: { - "@effect/vitest": "4.0.0-beta.102", - "@types/node": "20.19.25", - nx: "23.1.0", - typescript: "6.0.3", - vitest: "4.1.10", - }, - }, - null, - 2, - )}\n`, - "pnpm-workspace.yaml": "packages:\n - apps/*\n - packages/*/*\n", - "project.json": `${JSON.stringify( - { - name: "@effectify/todo-workspace", - targets: { - build: { executor: "nx:run-commands", options: { command: "pnpm exec tsc -p tsconfig.build.json" } }, - test: { executor: "nx:run-commands", options: { command: "pnpm exec vitest run --config vitest.config.mts" } }, - typecheck: { - executor: "nx:run-commands", - options: { command: "pnpm exec tsc --noEmit -p tsconfig.build.json" }, - }, - }, - }, - null, - 2, - )}\n`, - "tsconfig.build.json": `${JSON.stringify( - { - compilerOptions: { - module: "NodeNext", - moduleResolution: "NodeNext", - outDir: "dist", - rootDir: ".", - skipLibCheck: true, - strict: true, - target: "ES2022", - types: ["node"], - }, - include: ["apps/**/src/**/*.ts", "packages/**/src/**/*.ts"], - }, - null, - 2, - )}\n`, - "vitest.config.mts": `import { defineConfig } from "vitest/config"\n\nexport default defineConfig({ test: { environment: "node", include: ["apps/**/tests/**/*.test.ts", "packages/**/tests/**/*.test.ts"], watch: false } })\n`, - "apps/todo-cli/project.json": project("@effectify/todo-cli", "apps/todo-cli"), - "packages/todo/application/project.json": project("@effectify/todo-application", "packages/todo/application"), - "packages/todo/domain/project.json": project("@effectify/todo-domain", "packages/todo/domain"), - "packages/todo/infrastructure/project.json": project( - "@effectify/todo-infrastructure", - "packages/todo/infrastructure", - ), -}) +const generationRoot = createRequire(import.meta.url) + .resolve("@effectify/app-builder-generation") + .replace(/\/dist\/.*$/, "") +const packageRoots = ["contracts", "generation", "cli"].map((name) => join(dirname(generationRoot), name)) + +const installCli = async (workspace: string, registry: string, environment: NodeJS.ProcessEnv) => { + const tarballs: Array = [] + for (const root of packageRoots) { + const packed = await run(root, ["pnpm", "pack", "--pack-destination", join(workspace, "distribution")], environment) + if (packed.exitCode !== 0) throw new Error(`Unable to pack public CLI dependency: ${packed.stderr}`) + const tarball = packed.stdout.trim().split("\n").at(-1) + if (tarball === undefined) throw new Error("Package distribution did not produce a tarball") + tarballs.push(tarball) + } + const driver = join(workspace, "driver") + await write(join(driver, "package.json"), '{"private":true}\n') + const argv = ["npm", "install", "--registry", registry, ...tarballs, "effect@4.0.0-beta.102"] + const result = await run(driver, argv, environment) + if (result.exitCode !== 0) throw new Error(`Unable to install public CLI: ${result.stderr}${result.stdout}`) + return { argv, cli: join(driver, "node_modules/.bin/effectify-app-builder"), result } +} -const proofTest = `import { expect, it } from "@effect/vitest"\nimport { readFile } from "node:fs/promises"\nimport { join } from "node:path"\nimport * as Effect from "effect/Effect"\nimport { createLiveRuntime, renderEvent } from "../src/index.js"\n\nit.effect("executes generated Todo CLI CRUD through the Live runtime", () => Effect.gen(function* () {\n const state = join(process.cwd(), ".todo-state.json")\n const todo = yield* createLiveRuntime(state)\n const added = yield* todo.add("write the nested proof")\n const completed = yield* todo.complete(added.id)\n const removed = yield* todo.remove(added.id)\n expect(yield* todo.list()).toEqual([])\n expect([added, completed, removed].map((value, index) => renderEvent(index === 0 ? { _tag: "TodoAdded", todo: value } : index === 1 ? { _tag: "TodoCompleted", todo: value } : { _tag: "TodoRemoved", todo: value }))).toEqual(["added:" + added.id + ":write the nested proof", "completed:" + added.id + ":write the nested proof", "removed:" + added.id + ":write the nested proof"])\n expect(yield* Effect.promise(() => readFile(state, "utf8"))).toBe("[]\\n")\n}))\n` - -const materialize = async (workspace: string, effectTarball: string): Promise> => { - const { Planner, TodoPreset } = await Effect.runPromise(generation()) - const plan = await Effect.runPromise(Planner.planTodo(intent)) - const topology = await Effect.runPromise(TodoPreset.createTodoTopology(plan)) - const written: Array = [] - const localEffect = `file:${join(workspace, "distribution", effectTarball)}` - for (const file of topology.files) { - const target = join(workspace, file.path) - const current = await readFile(target, "utf8").catch(() => undefined) - if (current === undefined) { - await write(target, file.content.replace('"effect": "catalog:"', `"effect": "${localEffect}"`)) - written.push(file.path) +const generate = async (workspace: string, cli: string, environment: NodeJS.ProcessEnv) => { + const argv = [cli, "generate", "--events=jsonl"] + const result = await run( + tmpdir(), + argv, + environment, + commandTimeout, + `${JSON.stringify({ + version: "effectify.app-builder-cli-request/1", + command: "generate", + payload: { intent, workspace: basename(workspace) }, + })}\n`, + ) + if (result.exitCode !== 0) throw new Error(`Public CLI generation failed: ${result.stderr}${result.stdout}`) + const terminal = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "null") as { + readonly terminal?: { + readonly _tag?: string + readonly result?: { readonly writtenPaths?: ReadonlyArray } } } - for (const [path, content] of Object.entries(workspaceFiles())) { - await write(join(workspace, path), content) + if (terminal.terminal?._tag !== "Success" || terminal.terminal.result?.writtenPaths === undefined) { + throw new Error("Public CLI generation did not return successful materialization evidence") } - await write(join(workspace, "apps/todo-cli/tests/nested-proof.test.ts"), proofTest) - return written + return { argv, result, writtenPaths: terminal.terminal.result.writtenPaths } } +const runtimeProof = ` +const { readFile } = await import("node:fs/promises") +const Effect = await import("effect/Effect") +const { createLiveRuntime, renderEvent } = await import("./src/index.ts") +const evidence = await Effect.runPromise(Effect.gen(function* () { + const task = yield* createLiveRuntime(".task-state.json") + const added = yield* task.add("write the public proof") + const completed = yield* task.complete(added.id) + const removed = yield* task.remove(added.id) + return { id: added.id, events: [renderEvent({ _tag: "TaskAdded", task: added }), renderEvent({ _tag: "TaskCompleted", task: completed }), renderEvent({ _tag: "TaskRemoved", task: removed })], remaining: yield* task.list(), state: yield* Effect.promise(() => readFile(".task-state.json", "utf8")) } +})) +console.log(JSON.stringify(evidence)) +` + const snapshot = async (workspace: string, paths: ReadonlyArray): Promise>> => Object.fromEntries( await Promise.all(paths.map(async (path) => [path, await readFile(join(workspace, path), "utf8")])), @@ -280,7 +408,6 @@ const readRootProjects = async (): Promise> => { const registryConfig = (workspace: string) => `storage: ${join(workspace, "registry/storage")}\nauth:\n htpasswd:\n file: ${join(workspace, "registry/htpasswd")}\nuplinks:\n npmjs:\n url: https://registry.npmjs.org/\npackages:\n "@*/*":\n access: $all\n publish: $all\n proxy: npmjs\n "**":\n access: $all\n publish: $all\n proxy: npmjs\nlog:\n - { type: stdout, format: pretty, level: warn }\n` -const effectPackagePath = fileURLToPath(new URL("../node_modules/effect/", import.meta.url)) const verdaccioPath = fileURLToPath( new URL( "../../../../node_modules/.pnpm/verdaccio@6.7.4_encoding@0.1.13_typanion@3.14.0/node_modules/verdaccio/bin/verdaccio", @@ -288,120 +415,122 @@ const verdaccioPath = fileURLToPath( ), ) -/** Runs a real, bounded nested Nx proof and always removes all temporary process and filesystem state. */ +/** Runs a real installed public CLI proof and removes its isolated registry and files. */ export const runGoldenNestedWorkspace = async ({ scenario = "success", }: { readonly scenario?: Scenario } = {}): Promise => { const workspace = await mkdtemp(join(tmpdir(), "effectify-app-builder-e2e-")) - const state: WorkspaceState = { - environment: environmentFor(workspace), - store: join(workspace, "pnpm-store"), - } + const state: WorkspaceState = { environment: environmentFor(workspace), store: join(workspace, "pnpm-store") } const commands: Array = [] let registry: ChildProcess | undefined - let outcome: Scenario = scenario + let outcome = scenario let regeneration = { changedPaths: [] as ReadonlyArray, secondWritePaths: [] as ReadonlyArray } - let todo = { events: [] as ReadonlyArray, state: "[]\n" as const } + let task = { events: [] as ReadonlyArray, id: "", state: "[]\n" as const } + let termination: TerminationEvidence | undefined let cleanup = { daemonStopped: false, storeRemoved: false, workspaceRemoved: false } try { await mkdir(state.environment.HOME!, { recursive: true }) - const distribution = join(workspace, "distribution") - await mkdir(distribution, { recursive: true }) - const packed = await run(effectPackagePath, ["pnpm", "pack", "--pack-destination", distribution], state.environment) - if (packed.exitCode !== 0) throw new Error(`Unable to pack local Effect distribution: ${packed.stderr}`) - const [effectTarball] = (await readdir(distribution)).filter((entry) => entry.endsWith(".tgz")) - if (effectTarball === undefined) throw new Error("Local Effect package distribution did not produce a tarball") - const port = await freePort() const config = join(workspace, "registry/config.yaml") await write(config, registryConfig(workspace)) registry = spawn(process.execPath, [verdaccioPath, "--config", config, "--listen", `127.0.0.1:${port}`], { cwd: workspace, + detached: true, env: state.environment, shell: false, stdio: "ignore", }) - await waitForRegistry(`http://127.0.0.1:${port}`) + const registryUrl = `http://127.0.0.1:${port}` + await waitForRegistry(registryUrl) - const firstWritePaths = await materialize(workspace, effectTarball) - const lock = await run( - workspace, - ["pnpm", "install", "--lockfile-only", "--store-dir", state.store, "--registry", `http://127.0.0.1:${port}`], - state.environment, - ) + const installed = await installCli(workspace, registryUrl, state.environment) + commands.push({ argv: installed.argv, exitCode: installed.result.exitCode, label: "cli-install" }) + const firstGeneration = await generate(workspace, installed.cli, state.environment) + commands.push({ argv: firstGeneration.argv, exitCode: firstGeneration.result.exitCode, label: "generate" }) + + const registryArgs = ["--store-dir", state.store, "--registry", registryUrl] + const lock = await run(workspace, ["pnpm", "install", "--lockfile-only", ...registryArgs], state.environment) if (lock.exitCode !== 0) throw new Error(`Nested lockfile materialization failed: ${lock.stderr}${lock.stdout}`) - const installArgv = [ - "pnpm", - "install", - "--frozen-lockfile", - "--store-dir", - state.store, - "--registry", - `http://127.0.0.1:${port}`, - ] + const installArgv = ["pnpm", "install", "--frozen-lockfile", ...registryArgs] const install = await run(workspace, installArgv, state.environment) commands.push({ argv: installArgv, exitCode: install.exitCode, label: "install" }) if (install.exitCode !== 0) throw new Error(`Nested frozen install failed: ${install.stderr}${install.stdout}`) if (scenario === "verification-failure") { - const failure = await run(workspace, [process.execPath, "--eval", "process.exit(1)"], state.environment) + await appendFile(join(workspace, "apps/admin-console/src/index.ts"), "\nexport const invalidProof: string = 1\n") + const argv = ["pnpm", "exec", "nx", "run", "@acme/admin-console:typecheck"] + const failure = await run(workspace, argv, state.environment) commands.push({ - argv: [process.execPath, "--eval", "process.exit(1)"], + argv, exitCode: failure.exitCode, label: "verification-failure", + output: `${failure.stdout}${failure.stderr}`, }) - if (failure.exitCode === 0) throw new Error("Nested verification failure scenario unexpectedly passed") + const output = `${failure.stdout}${failure.stderr}` + if (failure.exitCode === 0 || !output.includes("src/index.ts") || !output.includes("TS2322")) { + throw new Error("Generated Nx typecheck did not report the intentional TypeScript corruption") + } } else if (scenario === "interrupted") { - const interrupted = await run( - workspace, - [process.execPath, "--eval", "setInterval(() => undefined, 1000)"], - state.environment, - 100, + const ready = join(workspace, ".interruption-ready.json") + await appendFile( + join(workspace, "apps/admin-console/tests/task.test.ts"), + `\nit("publishes interruption readiness", async () => {\n const { spawn } = await import("node:child_process")\n const descendant = spawn(process.execPath, ["-e", ${JSON.stringify(`const { writeFileSync } = require("node:fs"); const { createServer } = require("node:net"); process.on("SIGTERM", () => {}); createServer().listen(0, "127.0.0.1", () => writeFileSync(${JSON.stringify(ready)}, JSON.stringify({ descendantPid: process.pid })))`)}], { stdio: "ignore" })\n if (descendant.pid === undefined) throw new Error("resistant descendant did not start")\n await new Promise((resolve) => descendant.once("exit", resolve))\n})\n`, ) - commands.push({ - argv: [process.execPath, "--eval", "setInterval(() => undefined, 1000)"], - exitCode: interrupted.exitCode, - label: "interruption", - }) - if (interrupted.exitCode !== 130) throw new Error(`Nested interruption scenario returned ${interrupted.exitCode}`) + const argv = ["pnpm", "exec", "nx", "run", "@acme/admin-console:test"] + const interrupted = await run(workspace, argv, state.environment, commandTimeout, "", ready) + termination = interrupted.termination + commands.push({ argv, exitCode: interrupted.exitCode, label: "interruption" }) + if (interrupted.exitCode !== 130 || termination?.stopped !== true || termination.escalated !== true) { + throw new Error( + `Generated Vitest interruption lacked truthful termination evidence: ${JSON.stringify(termination)}`, + ) + } } else { const graphArgv = ["pnpm", "exec", "nx", "graph", "--print"] const graph = await run(workspace, graphArgv, state.environment) commands.push({ argv: graphArgv, exitCode: graph.exitCode, label: "graph" }) if (graph.exitCode !== 0) throw new Error(`Nested Nx graph failed: ${graph.stderr}`) - const names = Object.keys( + const projectNames = Object.keys( (JSON.parse(graph.stdout) as { readonly graph: { readonly nodes: Record } }).graph.nodes, - ) - for (const name of [ - "@effectify/todo-domain", - "@effectify/todo-application", - "@effectify/todo-infrastructure", - "@effectify/todo-cli", - ]) { - if (!names.includes(name)) throw new Error(`Nested Nx graph did not include ${name}`) + ).sort() + const expectedProjects = [ + "@acme/admin-console", + "@acme/operations-application", + "@acme/operations-domain", + "@acme/operations-infrastructure", + ] + if (JSON.stringify(projectNames) !== JSON.stringify(expectedProjects)) { + throw new Error(`Nested Nx graph did not match the expected projects: ${JSON.stringify(projectNames)}`) } + for (const label of ["test", "typecheck", "build"] as const) { - const argv = ["pnpm", "exec", "nx", "run", `@effectify/todo-workspace:${label}`] + const argv = ["pnpm", "exec", "nx", "run", `@acme/admin-console:${label}`] const result = await run(workspace, argv, state.environment) commands.push({ argv, exitCode: result.exitCode, label }) if (result.exitCode !== 0) throw new Error(`Nested ${label} failed: ${result.stderr}`) } - const beforeRegeneration = await snapshot(workspace, firstWritePaths) - const secondWritePaths = await materialize(workspace, effectTarball) - const afterRegeneration = await snapshot(workspace, firstWritePaths) - regeneration = { - changedPaths: firstWritePaths.filter((path) => beforeRegeneration[path] !== afterRegeneration[path]), - secondWritePaths, + const runtimeArgv = [process.execPath, "--input-type=module", "--eval", runtimeProof] + const runtime = await run(join(workspace, "apps/admin-console"), runtimeArgv, state.environment) + commands.push({ argv: runtimeArgv, exitCode: runtime.exitCode, label: "runtime" }) + if (runtime.exitCode !== 0) throw new Error(`Generated Live runtime failed: ${runtime.stderr}`) + const runtimeEvidence = JSON.parse(runtime.stdout) as { + readonly events: ReadonlyArray + readonly id: string + readonly remaining: ReadonlyArray + readonly state: "[]\n" } - todo = { - events: [ - "added:todo-1:write the nested proof", - "completed:todo-1:write the nested proof", - "removed:todo-1:write the nested proof", - ], - state: "[]\n", + if (runtimeEvidence.remaining.length !== 0) throw new Error("Generated Live runtime did not remove the Task") + + const beforeReplay = await snapshot(workspace, firstGeneration.writtenPaths) + const secondGeneration = await generate(workspace, installed.cli, state.environment) + commands.push({ argv: secondGeneration.argv, exitCode: secondGeneration.result.exitCode, label: "replay" }) + const afterReplay = await snapshot(workspace, firstGeneration.writtenPaths) + regeneration = { + changedPaths: firstGeneration.writtenPaths.filter((path) => beforeReplay[path] !== afterReplay[path]), + secondWritePaths: secondGeneration.writtenPaths, } + task = { events: runtimeEvidence.events, id: runtimeEvidence.id, state: runtimeEvidence.state } outcome = "success" } } finally { @@ -416,11 +545,11 @@ export const runGoldenNestedWorkspace = async ({ } const rootProjectNames = await readRootProjects() - if (rootProjectNames.some((name) => name.startsWith("@effectify/todo-"))) { - throw new Error("Nested Todo projects polluted root Nx discovery") + if (rootProjectNames.some((name) => name.startsWith("@acme/"))) { + throw new Error("Nested generated projects polluted root Nx discovery") } if (!cleanup.daemonStopped || !cleanup.storeRemoved || !cleanup.workspaceRemoved) { throw new Error("Nested E2E cleanup did not remove all isolated resources") } - return { cleanup, commands, outcome, regeneration, rootProjectNames, todo } + return { cleanup, commands, outcome, regeneration, rootProjectNames, task, termination } } diff --git a/packages/app-builder/e2e/tests/golden.e2e.test.ts b/packages/app-builder/e2e/tests/golden.e2e.test.ts index 7b5b6da0..8d5151ea 100644 --- a/packages/app-builder/e2e/tests/golden.e2e.test.ts +++ b/packages/app-builder/e2e/tests/golden.e2e.test.ts @@ -4,6 +4,7 @@ type CommandEvidence = { readonly argv: ReadonlyArray readonly exitCode: number readonly label: string + readonly output?: string } interface GoldenWorkspaceEvidence { @@ -19,27 +20,48 @@ interface GoldenWorkspaceEvidence { readonly secondWritePaths: ReadonlyArray } readonly rootProjectNames: ReadonlyArray - readonly todo: { + readonly task: { readonly events: ReadonlyArray + readonly id: string readonly state: "[]\n" } + readonly termination?: { + readonly descendantPid?: number + readonly descendantGone: boolean + readonly directChildClosed: boolean + readonly directChildClosedBeforeKill: boolean + readonly escalated: boolean + readonly groupId: number + readonly groupGone: boolean + readonly killSent: boolean + readonly readinessObserved: boolean + readonly stopped: boolean + readonly termSent: boolean + } } interface GoldenWorkspaceModule { - readonly runGoldenNestedWorkspace: (input?: { - readonly scenario?: "interrupted" | "success" | "verification-failure" + readonly runGoldenCommandTimeout: (timeout?: number) => Promise + readonly runGoldenNestedWorkspace: (options?: { + readonly scenario?: GoldenWorkspaceEvidence["outcome"] }) => Promise } const golden = () => import(new URL("../src/golden-workspace.js", import.meta.url).href) as Promise -it("R14 and S25-S27 generate an isolated Todo Nx workspace, run its real commands, and preserve zero-diff regeneration", async () => { +it("R14 and S25-S27 generate a custom workspace through the public CLI, run Nx and Live runtime, and replay cleanly", async () => { const Golden = await golden() const result = await Golden.runGoldenNestedWorkspace() expect(result.outcome).toBe("success") expect(result.commands).toEqual([ + expect.objectContaining({ label: "cli-install", exitCode: 0 }), + expect.objectContaining({ + label: "generate", + exitCode: 0, + argv: [expect.stringMatching(/\/effectify-app-builder$/), "generate", "--events=jsonl"], + }), expect.objectContaining({ label: "install", exitCode: 0, @@ -49,40 +71,85 @@ it("R14 and S25-S27 generate an isolated Todo Nx workspace, run its real command expect.objectContaining({ label: "test", exitCode: 0, - argv: ["pnpm", "exec", "nx", "run", "@effectify/todo-workspace:test"], + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:test"], }), expect.objectContaining({ label: "typecheck", exitCode: 0, - argv: ["pnpm", "exec", "nx", "run", "@effectify/todo-workspace:typecheck"], + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:typecheck"], }), expect.objectContaining({ label: "build", exitCode: 0, - argv: ["pnpm", "exec", "nx", "run", "@effectify/todo-workspace:build"], + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:build"], + }), + expect.objectContaining({ label: "runtime", exitCode: 0 }), + expect.objectContaining({ + label: "replay", + exitCode: 0, + argv: [expect.stringMatching(/\/effectify-app-builder$/), "generate", "--events=jsonl"], }), ]) - expect(result.todo.events).toEqual([ - "added:todo-1:write the nested proof", - "completed:todo-1:write the nested proof", - "removed:todo-1:write the nested proof", + expect(result.task.id).toMatch(/^[0-9a-f-]{36}$/) + expect(result.task.events).toEqual([ + `added:${result.task.id}:write the public proof`, + `completed:${result.task.id}:write the public proof`, + `removed:${result.task.id}:write the public proof`, ]) - expect(result.todo.state).toBe("[]\n") + expect(result.task.state).toBe("[]\n") expect(result.regeneration).toEqual({ changedPaths: [], secondWritePaths: [] }) - expect(result.rootProjectNames.filter((name) => name.startsWith("@effectify/todo-"))).toEqual([]) + expect(result.rootProjectNames.filter((name) => name.startsWith("@acme/"))).toEqual([]) expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) }, 300_000) -it("R15 and S28 clean isolated resources after nested verification failure and interruption without root Nx pollution", async () => { - const Golden = await golden() - const verificationFailure = await Golden.runGoldenNestedWorkspace({ scenario: "verification-failure" }) - const interrupted = await Golden.runGoldenNestedWorkspace({ scenario: "interrupted" }) +it("rejects a non-closing process on timeout without retaining its timer", async () => { + const timeoutCount = process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length - for (const result of [verificationFailure, interrupted]) { - expect(result.commands).toContainEqual(expect.objectContaining({ label: "install", exitCode: 0 })) - expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) - expect(result.rootProjectNames.filter((name) => name.startsWith("@effectify/todo-"))).toEqual([]) - } - expect(verificationFailure.outcome).toBe("verification-failure") - expect(interrupted.outcome).toBe("interrupted") + await expect((await golden()).runGoldenCommandTimeout(50)).rejects.toThrow("Process did not close within 50ms") + expect(process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length).toBeLessThanOrEqual( + timeoutCount, + ) +}) + +it("reports a real generated Nx typecheck failure and removes its isolated resources", async () => { + const timeoutCount = process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length + const result = await (await golden()).runGoldenNestedWorkspace({ scenario: "verification-failure" }) + + expect(result.outcome).toBe("verification-failure") + const failure = result.commands.find((command) => command.label === "verification-failure") + expect(failure?.argv).toEqual(["pnpm", "exec", "nx", "run", "@acme/admin-console:typecheck"]) + expect(failure?.exitCode).not.toBe(0) + expect(failure?.output).toContain("src/index.ts") + expect(failure?.output).toContain("TS2322") + expect(result.rootProjectNames.filter((name) => name.startsWith("@acme/"))).toEqual([]) + expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) + expect(process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length).toBeLessThanOrEqual( + timeoutCount, + ) +}, 300_000) + +it("interrupts a ready generated Vitest workflow and kills its TERM-resistant descendant", async () => { + const result = await (await golden()).runGoldenNestedWorkspace({ scenario: "interrupted" }) + + expect(result.outcome).toBe("interrupted") + expect(result.commands).toContainEqual({ + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:test"], + exitCode: 130, + label: "interruption", + }) + expect(result.termination).toEqual({ + descendantPid: expect.any(Number), + descendantGone: true, + directChildClosed: true, + directChildClosedBeforeKill: true, + escalated: true, + groupId: expect.any(Number), + groupGone: true, + killSent: true, + readinessObserved: true, + stopped: true, + termSent: true, + }) + expect(result.rootProjectNames.filter((name) => name.startsWith("@acme/"))).toEqual([]) + expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) }, 300_000) diff --git a/packages/app-builder/e2e/vitest.config.mts b/packages/app-builder/e2e/vitest.config.mts index da40e879..3ee89fa7 100644 --- a/packages/app-builder/e2e/vitest.config.mts +++ b/packages/app-builder/e2e/vitest.config.mts @@ -1,15 +1,8 @@ -import { fileURLToPath } from "node:url" import { defineConfig } from "vitest/config" export default defineConfig({ root: __dirname, cacheDir: "../../../node_modules/.vite/packages/app-builder/e2e", - resolve: { - alias: { - "@effectify/app-builder-contracts": fileURLToPath(new URL("../contracts/src/index.ts", import.meta.url)), - "@effectify/app-builder-generation": fileURLToPath(new URL("../generation/src/index.ts", import.meta.url)), - }, - }, test: { name: "@effectify/app-builder-e2e", environment: "node", 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 aa1c1c7f..38d848af 100644 --- a/packages/app-builder/generation/src/generators/index.ts +++ b/packages/app-builder/generation/src/generators/index.ts @@ -10,6 +10,8 @@ import { presentationBlock, presentationGenerator } from "./presentation.js" import { TodoGenerationBlockIds, todoSurfaceInput, type TodoGenerationBlockId } from "./todo.js" 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" @@ -43,6 +45,14 @@ export const composeTodoAtomic = (context: unknown, selected: ReadonlyArray composeCatalog({ @@ -56,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 new file mode 100644 index 00000000..0ada1398 --- /dev/null +++ b/packages/app-builder/generation/src/generators/todo-v1-atomic.ts @@ -0,0 +1,212 @@ +import { createHash } from "node:crypto" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as Kernel from "../kernel.js" +import { renderTemplate, templateAsset, templateSubstitutions } from "../templates.js" +const contribution = ( + context: Kernel.RenderContext, + sourcePath: string, + owner: string, + packageId: Kernel.FileContribution["package"], + path: string, + surface: string, +): 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), + package: packageId, + 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: (_input, context) => + Effect.succeed( + Object.freeze( + ["nx.json", "package.json", "pnpm-workspace.yaml", "tsconfig.build.json", "vitest.config.mts"].map((path) => + contribution( + context, + `workspace/${path}.template`, + `workspace-surface-${path.replace(/[^a-z0-9]+/g, "-")}`, + "workspace", + path, + "workspace", + ), + ), + ), + ), +} +const definitions = [ + ["domain", [], "todo-v1-domain-surface"], + ["application", ["domain"], "todo-v1-application-surface"], + ["infrastructure", ["application", "domain"], "todo-v1-infrastructure-surface"], + ["presentation", ["infrastructure", "application", "domain"], "todo-v1-presentation-surface"], +] as const +const packageSurface = ( + id: (typeof definitions)[number][0], + dependencies: ReadonlyArray, + capability: string, +): Kernel.AtomicGenerator => ({ + InputSchema: Schema.Unknown, + id: Kernel.identifier(capability), + provides: Kernel.capabilities(capability), + requires: Kernel.capabilities("todo-v1-workspace"), + version: "1", + render: (_input, context) => { + 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 owner = `package-surface-${id}` + return Effect.succeed( + Object.freeze([ + contribution( + 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`, + "package-surface", + ), + ]), + ) + }, +}) +const templateTest = (packageId: string, sourcePath: string, relativePath: string) => + [packageId, sourcePath, relativePath] as const +const leafFiles = { + 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": 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, sourcePath, relativePath] = leafFiles[id] + return { + InputSchema: Schema.Unknown, + id: Kernel.identifier(`todo-v1-${id}`), + provides: Kernel.capabilities(`todo-v1-${id}`), + requires: Kernel.capabilities(...requires), + version: "1", + 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", + }), + ) + if (sourcePath === undefined || relativePath === undefined) return Effect.succeed(Object.freeze([])) + const path = `${target.root}/${relativePath.replace("__entityId__", context.entity.id)}` + return Effect.succeed( + Object.freeze([ + contribution( + context, + sourcePath, + `todo-${id}-${path.replace(/[^a-z0-9]+/g, "-")}`, + target.id, + path, + "todo-capability", + ), + ]), + ) + }, + } +} +const [domainSurface, applicationSurface, infrastructureSurface, presentationSurface] = definitions.map( + ([id, dependencies, capability]) => packageSurface(id, dependencies, capability), +) +const model = leaf("model", ["todo-v1-domain-surface"]) +const event = leaf("event", ["todo-v1-model", "todo-v1-domain-surface"]) +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, + applicationSurface, + infrastructureSurface, + presentationSurface, + model, + event, + port, + useCase, + adapter, + presentation, +]) +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 f66fc9dd..793a4e04 100644 --- a/packages/app-builder/generation/src/index.ts +++ b/packages/app-builder/generation/src/index.ts @@ -3,10 +3,13 @@ export * as GenerationKernel from "./kernel.js" export { composeCatalog } from "./catalog.js" export * as Intent from "./intent.js" export * as Planner from "./planner.js" +export * as Pr130Regeneration from "./regeneration.js" export * as Replay from "./replay.js" 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 { @@ -24,7 +27,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/regeneration.ts b/packages/app-builder/generation/src/regeneration.ts new file mode 100644 index 00000000..86673f75 --- /dev/null +++ b/packages/app-builder/generation/src/regeneration.ts @@ -0,0 +1,89 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" + +export const Pr130EvidenceClass = Schema.Literals([ + "unit-1-replay-completeness", + "unit-2-typed-kernel-catalog", + "unit-3-generation-surfaces", + "unit-4-atomic-todo-materialization", + "unit-5-todo-v1-parity", + "unit-6-public-cli-lifecycle", +]) +export type Pr130EvidenceClass = typeof Pr130EvidenceClass.Type +const CanonicalEvidenceClasses = Object.freeze(Pr130EvidenceClass.literals) + +export const Pr130FrozenHead = "d5928f99a122b04cb70ff68bfa02fa2d5648a5fc" + +export const Pr130ExpectedEvidenceIdentities: Readonly> = Object.freeze({ + "unit-1-replay-completeness": "613a9654a183910956cc72153f7f8c33b3a78282", + "unit-2-typed-kernel-catalog": "b85aabf05d6cb800e017cd5f4dc4e2ccc6a03e7f", + "unit-3-generation-surfaces": "76aab96389f0f0c7c4ab02b20dbf53b7e60abcfa", + "unit-4-atomic-todo-materialization": "21b92cc564ac806a82e50640552fdf8f0d6d674a", + "unit-5-todo-v1-parity": "2ad43e0cca44a0ed34db27fa29b344a71fc8e214", + "unit-6-public-cli-lifecycle": "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", +}) + +export const Pr130RegenerationProof = Schema.Struct({ + version: Schema.Literal("effectify.app-builder-pr130-regeneration-proof/1"), + evidence: Schema.Array( + Schema.Struct({ + evidenceClass: Pr130EvidenceClass, + identity: Schema.String, + }), + ), + pr130Head: Schema.String, +}) +export type Pr130RegenerationProof = typeof Pr130RegenerationProof.Type + +export const Pr130RegenerationBlockReason = Schema.TaggedUnion({ + MissingEvidence: { evidenceClass: Pr130EvidenceClass }, + MismatchedEvidence: { + actualIdentities: Schema.Array(Schema.String), + evidenceClass: Pr130EvidenceClass, + expectedIdentity: Schema.String, + }, + WrongPr130Head: { actualHead: Schema.String, expectedHead: Schema.String }, +}) +export type Pr130RegenerationBlockReason = typeof Pr130RegenerationBlockReason.Type + +export class Pr130RegenerationBlocked extends Data.TaggedError("Pr130RegenerationBlocked")<{ + readonly reasons: ReadonlyArray +}> {} + +export interface Pr130RegenerationEligibility { + readonly prNumber: 130 + readonly status: "eligible" +} + +const eligibility: Pr130RegenerationEligibility = Object.freeze({ prNumber: 130, status: "eligible" }) + +/** Evaluates canonical evidence only; it owns no external read or mutation capability. */ +export const requirePr130RegenerationEligibility = ( + proof: Pr130RegenerationProof, +): Effect.Effect => { + const reasons: Array = [] + for (const evidenceClass of CanonicalEvidenceClasses) { + const expectedIdentity = Pr130ExpectedEvidenceIdentities[evidenceClass] + const actualIdentities = proof.evidence + .filter((evidence) => evidence.evidenceClass === evidenceClass) + .map((evidence) => evidence.identity) + .sort() + if (actualIdentities.length === 0) { + reasons.push({ _tag: "MissingEvidence", evidenceClass }) + } else if (actualIdentities.length !== 1 || actualIdentities[0] !== expectedIdentity) { + reasons.push({ + _tag: "MismatchedEvidence", + actualIdentities: Object.freeze(actualIdentities), + evidenceClass, + expectedIdentity, + }) + } + } + if (proof.pr130Head !== Pr130FrozenHead) { + reasons.push({ _tag: "WrongPr130Head", actualHead: proof.pr130Head, expectedHead: Pr130FrozenHead }) + } + return reasons.length === 0 + ? Effect.succeed(eligibility) + : Effect.fail(new Pr130RegenerationBlocked({ reasons: Object.freeze(reasons) })) +} 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..d74ff27e --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template @@ -0,0 +1,21 @@ +{ + "defaultBase": "HEAD", + "plugins": [ + { + "plugin": "@nx/js/typescript", + "exclude": ["tsconfig.build.json"], + "options": { + "typecheck": { + "targetName": "typecheck" + } + } + }, + { + "plugin": "@nx/vitest", + "exclude": ["vitest.config.mts"], + "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 a2ceaa33..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", 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/src/todo-v1.ts b/packages/app-builder/generation/src/todo-v1.ts new file mode 100644 index 00000000..7bf1341a --- /dev/null +++ b/packages/app-builder/generation/src/todo-v1.ts @@ -0,0 +1,73 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" + +export type TodoV1OwnerIdentity = Readonly<{ owner: string; path: string }> +export class TodoV1OwnerMigrationError extends Data.TaggedError("TodoV1OwnerMigrationError")<{ + readonly path: string + readonly reason: + | "already-invalid-owner" + | "ambiguous-owner" + | "duplicate-path" + | "mismatched-path" + | "missing-output" + | "unknown-owner" +}> {} + +const roots = ["nx.json", "package.json", "pnpm-workspace.yaml", "tsconfig.build.json", "vitest.config.mts"] +const packages = [ + ["packages/todo/domain", "domain", "model"], + ["packages/todo/application", "application", "port"], + ["packages/todo/infrastructure", "infrastructure", "integration-adapter"], + ["apps/todo-cli", "presentation", "presentation"], +] as const +const leaves: Readonly> = { + "packages/todo/application/src/use-case.ts": ["use-case", "todo-use-case-src-use-case-ts"], + "packages/todo/domain/src/events.ts": ["event", "todo-event-src-events-ts"], + "packages/todo/domain/tests/todo.test.ts": ["model", "todo-model-tests-todo-test-ts"], + "packages/todo/infrastructure/tests/todo-runtime.test.ts": [ + "integration-adapter", + "todo-integration-adapter-tests-todo-runtime-test-ts", + ], + "apps/todo-cli/tests/todo.test.ts": ["presentation", "todo-presentation-tests-todo-test-ts"], +} +const legacy = (owner: string) => `@effectify/app-builder/${owner}/1` +const expectedPaths = new Set([ + ...roots, + ...packages.flatMap(([root]) => [`${root}/package.json`, `${root}/src/index.ts`]), + ...Object.keys(leaves), +]) +const expected = (path: string): readonly [legacy: string, current: string] | undefined => { + if (roots.includes(path)) return [legacy("workspace"), `workspace-surface-${path.replace(/[^a-z0-9]+/g, "-")}`] + const surface = packages.find(([root]) => path === `${root}/package.json` || path === `${root}/src/index.ts`) + if (surface !== undefined) + return [ + legacy(path.endsWith("package.json") ? "workspace" : surface[2]), + `package-surface-${surface[1]}-${path.endsWith("package.json") ? "manifest" : "barrel"}`, + ] + const leaf = leaves[path] + return leaf === undefined ? undefined : [legacy(leaf[0]), `todo-${leaf[0]}-${path.replace(/[^a-z0-9]+/g, "-")}`] +} +const currentOwners = new Set([...expectedPaths].flatMap((path) => expected(path)?.[1] ?? [])) +const legacyOwners = new Set([...expectedPaths].flatMap((path) => expected(path)?.[0] ?? [])) +const failure = (path: string, reason: TodoV1OwnerMigrationError["reason"]) => + Effect.fail(new TodoV1OwnerMigrationError({ path, reason })) + +export const migrateTodoV1Owners = ( + identities: ReadonlyArray, +): Effect.Effect, TodoV1OwnerMigrationError> => + Effect.gen(function* () { + const paths = new Set() + const migrated: Array = [] + for (const identity of identities) { + if (paths.has(identity.path)) return yield* failure(identity.path, "duplicate-path") + paths.add(identity.path) + const owner = expected(identity.path) + if (owner === undefined) return yield* failure(identity.path, "mismatched-path") + if (currentOwners.has(identity.owner)) return yield* failure(identity.path, "already-invalid-owner") + if (identity.owner === "package-surface") return yield* failure(identity.path, "ambiguous-owner") + if (!legacyOwners.has(identity.owner)) return yield* failure(identity.path, "unknown-owner") + if (identity.owner !== owner[0]) return yield* failure(identity.path, "mismatched-path") + migrated.push(Object.freeze({ ...identity, owner: owner[1] })) + } + return paths.size === expectedPaths.size ? Object.freeze(migrated) : yield* failure("todo-v1", "missing-output") + }) 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/fixtures/todo-v1.ts b/packages/app-builder/generation/tests/fixtures/todo-v1.ts new file mode 100644 index 00000000..e1219a6b --- /dev/null +++ b/packages/app-builder/generation/tests/fixtures/todo-v1.ts @@ -0,0 +1,24 @@ +type Entry = ReadonlyArray +/** Frozen Todo v1 bytes and ownership transitions; never derive this from a renderer. */ +export const TodoV1Fixture: ReadonlyArray = Object.freeze( + `apps/todo-cli/package.json|1f7e39c504515ed23d106e172ccc2f0ef91186021b3a02e9066fe14ea4e2783e|@effectify/app-builder/workspace/1|package-surface-presentation-manifest +apps/todo-cli/src/index.ts|40f8ce0bfcb8db9c548ef19dd3509319e2c69ad03b27c57914d4788536b43eb5|@effectify/app-builder/presentation/1|package-surface-presentation-barrel +apps/todo-cli/tests/todo.test.ts|77b9145ff6a68b6c4660948ab8066e3e3091500e61135cbba4f748395f3d8074|@effectify/app-builder/presentation/1|todo-presentation-apps-todo-cli-tests-todo-test-ts +nx.json|36ebfd6eabb0aa4a5a4060dd694d745b9ce16717f4027bb9107c63b1e782af52|@effectify/app-builder/workspace/1|workspace-surface-nx-json +package.json|3b369e67b69581211e14df72d231a2331399888fe8f16c5095bab03b4616e426|@effectify/app-builder/workspace/1|workspace-surface-package-json +packages/todo/application/package.json|74a25e6b222c0e8e6ce8cf39df7e7a68d7b68faaaf01a1321d6d1c521f147f00|@effectify/app-builder/workspace/1|package-surface-application-manifest +packages/todo/application/src/index.ts|a606cc63ce4e3ab6eeb2345cdaf9c4bbd3bfb43f5a20f26bff250439194a154b|@effectify/app-builder/port/1|package-surface-application-barrel +packages/todo/application/src/use-case.ts|fce13645ef2926c343137b96fd0b2a0cd9b2e73046d8608ff3daa7204755c7df|@effectify/app-builder/use-case/1|todo-use-case-packages-todo-application-src-use-case-ts +packages/todo/domain/package.json|9ed2f277b6b9bf848e1c077d7a600aea00c391a05d558cc6ba0ca8134023baca|@effectify/app-builder/workspace/1|package-surface-domain-manifest +packages/todo/domain/src/events.ts|3826f139fcac911e8b84298d319400e2265730c62460b40501b3c1668be950f8|@effectify/app-builder/event/1|todo-event-packages-todo-domain-src-events-ts +packages/todo/domain/src/index.ts|e003ca3479b14b5d3f94fdd0c8db7ffe20269aad29628e0d09f7f367cd2cc7f8|@effectify/app-builder/model/1|package-surface-domain-barrel +packages/todo/domain/tests/todo.test.ts|99e4f463a497275f7bf6dacbe0402dde1b054dbe8e0568256dccd6a6a5448514|@effectify/app-builder/model/1|todo-model-packages-todo-domain-tests-todo-test-ts +packages/todo/infrastructure/package.json|5a28a70e873ee887a1819bafa2f11ae4d47cd44aaaac410d621493346d186889|@effectify/app-builder/workspace/1|package-surface-infrastructure-manifest +packages/todo/infrastructure/src/index.ts|73bfa06f4aac3cfee6fad9425490d86dbfab70ec79a69d2559a41c93bd391e35|@effectify/app-builder/integration-adapter/1|package-surface-infrastructure-barrel +packages/todo/infrastructure/tests/todo-runtime.test.ts|8a305172097e942a07398ee5bfeec90e2a84a07ea5b97050138c8549dcf9949a|@effectify/app-builder/integration-adapter/1|todo-integration-adapter-packages-todo-infrastructure-tests-todo-runtime-test-ts +pnpm-workspace.yaml|38153e8982dc83ad4f576c62dd12e80fd222668b7ebd29c9c89092a41bf10544|@effectify/app-builder/workspace/1|workspace-surface-pnpm-workspace-yaml +tsconfig.build.json|a12ff29d34e79ed58b9d9540572968f6f32721482f776500b2f2495810926138|@effectify/app-builder/workspace/1|workspace-surface-tsconfig-build-json +vitest.config.mts|3d6177cfd34114394a841bb24fdf661f2879b0b75cfe2d793ae414d2191081de|@effectify/app-builder/workspace/1|workspace-surface-vitest-config-mts` + .split("\n") + .map((line) => Object.freeze(line.split("|"))), +) 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/regeneration.test.ts b/packages/app-builder/generation/tests/regeneration.test.ts new file mode 100644 index 00000000..6a038cc7 --- /dev/null +++ b/packages/app-builder/generation/tests/regeneration.test.ts @@ -0,0 +1,161 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Result from "effect/Result" +import * as Generation from "../src/index.js" +import { + Pr130EvidenceClass, + Pr130FrozenHead, + type Pr130RegenerationProof, + requirePr130RegenerationEligibility, +} from "../src/regeneration.js" + +const ExpectedCanonicalEvidence: Pr130RegenerationProof["evidence"] = Object.freeze([ + { + evidenceClass: "unit-1-replay-completeness", + identity: "613a9654a183910956cc72153f7f8c33b3a78282", + }, + { + evidenceClass: "unit-2-typed-kernel-catalog", + identity: "b85aabf05d6cb800e017cd5f4dc4e2ccc6a03e7f", + }, + { + evidenceClass: "unit-3-generation-surfaces", + identity: "76aab96389f0f0c7c4ab02b20dbf53b7e60abcfa", + }, + { + evidenceClass: "unit-4-atomic-todo-materialization", + identity: "21b92cc564ac806a82e50640552fdf8f0d6d674a", + }, + { + evidenceClass: "unit-5-todo-v1-parity", + identity: "2ad43e0cca44a0ed34db27fa29b344a71fc8e214", + }, + { + evidenceClass: "unit-6-public-cli-lifecycle", + identity: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + }, +]) +const ExpectedCanonicalClasses = Object.freeze(ExpectedCanonicalEvidence.map(({ evidenceClass }) => evidenceClass)) +const ExpectedPr130Head = "d5928f99a122b04cb70ff68bfa02fa2d5648a5fc" + +const completeProof = (): Pr130RegenerationProof => ({ + version: "effectify.app-builder-pr130-regeneration-proof/1", + evidence: ExpectedCanonicalEvidence.map((evidence) => ({ ...evidence })), + pr130Head: ExpectedPr130Head, +}) + +it.effect("incomplete canonical proof blocks PR #130 regeneration before effects", () => + Effect.gen(function* () { + const failure = yield* requirePr130RegenerationEligibility({ + ...completeProof(), + evidence: [], + }).pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "Pr130RegenerationBlocked" }) + expect(failure.reasons).toEqual( + ExpectedCanonicalClasses.map((evidenceClass) => ({ _tag: "MissingEvidence", evidenceClass })), + ) + }), +) + +it.effect("each canonical Unit evidence class is independently required", () => + Effect.gen(function* () { + for (const missing of ExpectedCanonicalClasses) { + const proof = completeProof() + const failure = yield* requirePr130RegenerationEligibility({ + ...proof, + evidence: proof.evidence.filter(({ evidenceClass }) => evidenceClass !== missing), + }).pipe(Effect.flip) + + expect(failure.reasons).toEqual([{ _tag: "MissingEvidence", evidenceClass: missing }]) + } + }), +) + +it.effect("mismatched evidence identities block PR #130 regeneration deterministically", () => + Effect.gen(function* () { + const evidence = completeProof() + .evidence.map(({ evidenceClass }) => ({ evidenceClass, identity: `stale:${evidenceClass}` })) + .reverse() + const failure = yield* requirePr130RegenerationEligibility({ ...completeProof(), evidence }).pipe(Effect.flip) + + expect(failure.reasons).toEqual( + ExpectedCanonicalEvidence.map(({ evidenceClass, identity }) => ({ + _tag: "MismatchedEvidence", + actualIdentities: [`stale:${evidenceClass}`], + evidenceClass, + expectedIdentity: identity, + })), + ) + }), +) + +it.effect("wrong frozen PR #130 head blocks eligibility", () => + Effect.gen(function* () { + const failure = yield* requirePr130RegenerationEligibility({ + ...completeProof(), + pr130Head: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + }).pipe(Effect.flip) + + expect(failure.reasons).toEqual([ + { + _tag: "WrongPr130Head", + actualHead: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + expectedHead: Pr130FrozenHead, + }, + ]) + }), +) + +it.effect("complete canonical proof returns regeneration eligibility only", () => + Effect.gen(function* () { + const proof = Object.freeze({ + ...completeProof(), + evidence: Object.freeze(completeProof().evidence.map((entry) => Object.freeze(entry))), + }) + const before = structuredClone(proof) + const eligibility = yield* requirePr130RegenerationEligibility(proof) + + expect(eligibility).toEqual({ prNumber: 130, status: "eligible" }) + expect(proof).toEqual(before) + }), +) + +it("frozen PR #130 head remains typed input evidence only", () => { + const source = readFileSync(fileURLToPath(new URL("../src/regeneration.ts", import.meta.url)), "utf8") + + expect(Pr130FrozenHead).toBe("d5928f99a122b04cb70ff68bfa02fa2d5648a5fc") + expect(source).not.toMatch(/node:|github|octokit|\bfetch\b|Effect\.(promise|tryPromise)/i) +}) + +it.effect("public evidence metadata cannot weaken the canonical Unit 6 requirement", () => + Effect.gen(function* () { + const publicClasses = Pr130EvidenceClass.literals + const original = [...publicClasses] + const deleted = Reflect.deleteProperty(publicClasses, "5") + const shortened = Reflect.set(publicClasses, "length", 5) + const outcome = yield* Effect.result( + requirePr130RegenerationEligibility({ + ...completeProof(), + evidence: ExpectedCanonicalEvidence.filter( + ({ evidenceClass }) => evidenceClass !== "unit-6-public-cli-lifecycle", + ), + }), + ) + for (const [index, evidenceClass] of original.entries()) { + Reflect.set(publicClasses, String(index), evidenceClass) + } + Reflect.set(publicClasses, "length", original.length) + + expect(Result.isFailure(outcome)).toBe(true) + if (Result.isFailure(outcome)) { + expect(outcome.failure.reasons).toEqual([ + { _tag: "MissingEvidence", evidenceClass: "unit-6-public-cli-lifecycle" }, + ]) + } + expect("Pr130EvidenceClasses" in Generation.Pr130Regeneration).toBe(false) + expect([deleted, shortened, Object.isFrozen(publicClasses)]).toEqual([false, false, true]) + }), +) 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 a29a059c..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"], @@ -102,27 +103,30 @@ it.effect("each Todo capability closes only its real implementation prerequisite }), ) -it.effect("semantically compiles reordered bytes and rejects missing runtime imports", () => - Effect.gen(function* () { - const Todo = yield* generators() - const plan = yield* Todo.composeTodoAtomic({ ...context(), packages: [...context().packages].reverse() }) - const files = Object.fromEntries( - plan.contributions.map((file) => [file.path, new TextDecoder().decode(file.bytes)]), - ) - yield* compile(files) - expect(Object.values(files).join("\n")).not.toContain("packages/todo/") - expect(yield* Todo.composeTodoAtomic(context())).toEqual(plan) - expect(plan.generatorIds.slice(0, 2)).toEqual(["workspace-surface", "package-surface"]) - expect(new Set(plan.contributions.map((file) => file.path)).size).toBe(plan.contributions.length) - expect(new Set(plan.contributions.map((file) => file.owner)).size).toBe(plan.contributions.length) - const imports = [ - ["modules/task-core/src/model.ts", 'import * as Data from "effect/Data"\n', "Data"], - ["modules/task-service/src/port.ts", 'import * as Context from "effect/Context"\n', "Context"], - ["tools/task-cli/src/presentation.ts", 'import * as Effect from "effect/Effect"\n', "Effect"], - ] as const - for (const [path, source, symbol] of imports) { - const error = yield* compile({ ...files, [path]: files[path].replace(source, "") }).pipe(Effect.flip) - expect(error).toMatch(new RegExp(`Cannot find (name|namespace) '${symbol}'`)) - } - }), +it.effect( + "semantically compiles reordered bytes and rejects missing runtime imports", + () => + Effect.gen(function* () { + const Todo = yield* generators() + const plan = yield* Todo.composeTodoAtomic({ ...context(), packages: [...context().packages].reverse() }) + const files = Object.fromEntries( + plan.contributions.map((file) => [file.path, new TextDecoder().decode(file.bytes)]), + ) + yield* compile(files) + expect(Object.values(files).join("\n")).not.toContain("packages/todo/") + expect(yield* Todo.composeTodoAtomic(context())).toEqual(plan) + expect(plan.generatorIds.slice(0, 2)).toEqual(["workspace-surface", "package-surface"]) + expect(new Set(plan.contributions.map((file) => file.path)).size).toBe(plan.contributions.length) + expect(new Set(plan.contributions.map((file) => file.owner)).size).toBe(plan.contributions.length) + const imports = [ + ["modules/task-core/src/model.ts", 'import * as Data from "effect/Data"\n', "Data"], + ["modules/task-service/src/port.ts", 'import * as Context from "effect/Context"\n', "Context"], + ["tools/task-cli/src/presentation.ts", 'import * as Effect from "effect/Effect"\n', "Effect"], + ] as const + for (const [path, source, symbol] of imports) { + const error = yield* compile({ ...files, [path]: files[path].replace(source, "") }).pipe(Effect.flip) + expect(error).toMatch(new RegExp(`Cannot find (name|namespace) '${symbol}'`)) + } + }), + 15_000, ) diff --git a/packages/app-builder/generation/tests/todo-behavior.test.ts b/packages/app-builder/generation/tests/todo-behavior.test.ts index f2cd31e5..4883a378 100644 --- a/packages/app-builder/generation/tests/todo-behavior.test.ts +++ b/packages/app-builder/generation/tests/todo-behavior.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "@effect/vitest" -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises" import { dirname, join } from "node:path" import { pathToFileURL } from "node:url" import * as Effect from "effect/Effect" @@ -49,6 +49,19 @@ const modules = () => TodoPreset: Effect.promise(() => import(new URL("../src/todo-preset.js", import.meta.url).href)), }) +const linkGeneratedPackages = async (workspace: string): Promise => { + for (const [name, root] of [ + ["todo-domain", "packages/todo/domain"], + ["todo-application", "packages/todo/application"], + ["todo-infrastructure", "packages/todo/infrastructure"], + ["todo-cli", "apps/todo-cli"], + ] as const) { + const target = join(workspace, "node_modules", "@effectify", name) + await mkdir(dirname(target), { recursive: true }) + await symlink(join(workspace, root), target, "dir") + } +} + const withGeneratedTodo = ( use: (cli: TodoCliModule, workspace: string) => Effect.Effect, ): Effect.Effect => @@ -60,6 +73,7 @@ const withGeneratedTodo = ( yield* Effect.tryPromise(() => mkdir(dirname(join(workspace, file.path)), { recursive: true })) yield* Effect.tryPromise(() => writeFile(join(workspace, file.path), file.content)) } + yield* Effect.tryPromise(() => linkGeneratedPackages(workspace)) const cli = yield* Effect.promise( () => import(pathToFileURL(join(workspace, "apps/todo-cli/src/index.ts")).href), ) diff --git a/packages/app-builder/generation/tests/todo-v1.test.ts b/packages/app-builder/generation/tests/todo-v1.test.ts new file mode 100644 index 00000000..bb1671f2 --- /dev/null +++ b/packages/app-builder/generation/tests/todo-v1.test.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto" +import { expect, it } from "@effect/vitest" +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 { 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]) +const expectedOwners = TodoV1Fixture.map(([path, _digest, _legacy, owner]) => [path, owner]) +const digests = (files: ReadonlyArray<{ readonly content: string; readonly path: string }>) => + files + .map((file) => [file.path, createHash("sha256").update(file.content).digest("hex")]) + .sort(([left], [right]) => left.localeCompare(right)) +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) + const [publicTopology, direct] = yield* Effect.all([ + TodoPreset.createTodoTopology(plan), + TodoGeneration.composeTodoAtomic(TodoPreset.DefaultTodoRenderContext), + ]) + expect([Object.isFrozen(TodoV1Fixture), TodoV1Fixture.length]).toEqual([true, 18]) + expect(digests(publicTopology.files)).toEqual(expectedOutputs) + expect( + direct.contributions.map((file) => [file.path, createHash("sha256").update(file.bytes).digest("hex")]), + ).toEqual(expectedOutputs) + expect(direct.contributions.map(({ owner, path }) => [path, owner])).toEqual(expectedOwners) + }), +) +it.effect("renders independent names through the public intent, planner, and topology route", () => + Effect.gen(function* () { + 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", + ]), + ) + 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