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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/app-builder/cli/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ const workspacePath = (workspace: string): Effect.Effect<string, GenerateInputEr
: Effect.succeed(target)
}

const outputPath = (workspace: string, path: string): string => {
const outputPath = (workspace: string, path: string, roots: ReadonlyArray<string>): 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" })
}
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 25 additions & 8 deletions packages/app-builder/cli/tests/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MainModule>(() => import(new URL("../src/main.js", import.meta.url).href))

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string>(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)
Expand Down
2 changes: 2 additions & 0 deletions packages/app-builder/generation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"type": "module",
"dependencies": {
"@effectify/app-builder-contracts": "workspace:*",
"ejs": "5.0.1",
"tslib": "catalog:"
},
"peerDependencies": {
Expand All @@ -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:"
Expand Down
10 changes: 9 additions & 1 deletion packages/app-builder/generation/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
Expand Down
7 changes: 0 additions & 7 deletions packages/app-builder/generation/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ export const composeCatalog = <
}

const contributions: Array<FileContribution> = []
const owners = new Set<string>()
const paths = new Set<string>()
for (const id of generatorIds) {
const generator = byId.get(id)
Expand All @@ -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 }))
}
Expand Down
25 changes: 20 additions & 5 deletions packages/app-builder/generation/src/generators/block.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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"

export interface GenerationBlockFile {
readonly content: string
readonly owner: string
readonly path: string
readonly template?: import("../templates.js").TemplateAsset
}

export interface GenerationBlock {
Expand All @@ -24,10 +26,23 @@ interface Definition {
readonly requires: ReadonlyArray<TodoGenerationBlockId>
}

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`
Expand Down
9 changes: 3 additions & 6 deletions packages/app-builder/generation/src/generators/event.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
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"],
requires: ["todo-model"],
})

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"],
Expand Down
16 changes: 16 additions & 0 deletions packages/app-builder/generation/src/generators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TodoGenerationBlockIds, todoSurfaceInput, type TodoGenerationBlockId }
import { packageSurfaceGenerator, workspaceSurfaceGenerator } from "./surfaces.js"
import { useCaseBlock, useCaseGenerator } from "./use-case.js"
import { isTodoV1Context, TodoV1AtomicCatalog, todoV1GeneratorIds } from "./todo-v1-atomic.js"
export { DefaultTodoRenderContext } from "./todo-v1-atomic.js"

export { packageSurfaceGenerator, SurfaceCatalog, WorkspaceRootFiles, workspaceSurfaceGenerator } from "./surfaces.js"
export type { WorkspaceRootFile } from "./surfaces.js"
Expand Down Expand Up @@ -65,6 +66,21 @@ export const composeTodoAtomic = (context: unknown, selected: ReadonlyArray<stri
}),
)

export const composeTodoV1Atomic = (context: unknown, selected: ReadonlyArray<string> = 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<TodoGenerationBlockId, GenerationBlock> = new Map([
[modelBlock.id, modelBlock],
[portBlock.id, portBlock],
Expand Down
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
8 changes: 4 additions & 4 deletions packages/app-builder/generation/src/generators/model.ts
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
5 changes: 1 addition & 4 deletions packages/app-builder/generation/src/generators/port.ts
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
Loading
Loading