diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 00fd61b..39b8850 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -404,7 +404,10 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { publisher?: string; }; if (a.mode === "publish") return publishActions(a); - throw new Error("consume mode is wired in Task 40"); + if (!a.publisher) { + throw new Error("consume mode requires a publisher feature"); + } + return consumeActions(a as Required); }, }); } @@ -443,6 +446,128 @@ function publishActions(a: { ]; } +function consumeActions(a: { + feature: string; + event: string; + publisher: string; +}): PlopTypes.ActionType[] { + const eventKebab = a.event.replace(/\./g, "-"); + const handlerName = `on${pascalCase(a.publisher)}${pascalCase(a.event)}`; + const symbolFile = `packages/${a.feature}/src/di/symbols.ts`; + const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`; + const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`; + const cmsIndexFile = `packages/${a.feature}/src/integrations/cms/index.ts`; + return [ + () => { + assertAnchors(process.cwd(), symbolFile, ["// "]); + assertAnchors(process.cwd(), bindProdFile, ["// "]); + assertAnchors(process.cwd(), bindDevFile, ["// "]); + assertAnchors(process.cwd(), cmsIndexFile, ["// "]); + return "All required anchors present"; + }, + { + type: "add", + path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.ts`, + templateFile: "templates/event/consume/handler.ts.hbs", + data: a, + }, + { + type: "add", + path: `packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.test.ts`, + templateFile: "templates/event/consume/handler.test.ts.hbs", + data: a, + }, + { + type: "add", + path: `packages/${a.feature}/src/integrations/cms/jobs/__events-${a.publisher}-${eventKebab}.task.ts`, + templateFile: "templates/event/consume/event-task.ts.hbs", + data: a, + }, + { + type: "modify", + path: symbolFile, + pattern: /\/\/ /, + template: `// \n IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler: Symbol.for("@repo/${a.feature}/${handlerName}"),`, + }, + { + type: "modify", + path: bindProdFile, + pattern: /\/\/ /, + template: handlerBindBlock(a), + }, + { + type: "modify", + path: bindDevFile, + pattern: /\/\/ /, + template: handlerBindBlock(a), + }, + { + type: "modify", + path: cmsIndexFile, + pattern: /\/\/ /, + template: `// \nexport { on${pascalCase(a.publisher)}${pascalCase(a.event)}EventTask } from "./jobs/__events-${a.publisher}-${eventKebab}.task";`, + }, + () => printConsumeNextSteps(a), + ]; +} + +function handlerBindBlock(a: { + feature: string; + event: string; + publisher: string; +}): string { + const handlerFn = `on${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`; + const eventConst = `${camel(a.event.replace(/\./g, "-"))}Event`; + const handlerSymbol = `${constantCase(a.feature)}_SYMBOLS.IOn${pascalCase(a.publisher)}${pascalCase(a.event)}Handler`; + const wrappedVar = `wrapped${pascalCase(a.publisher)}${pascalCase(a.event)}`; + const containerVar = `${camel(a.feature)}Container`; + return `// + // ${handlerFn} subscription — generated, edit the handler file (not this block) for behavior. + const ${wrappedVar} = withSpan( + tracer, + { name: "${a.feature}.${handlerFn}", op: "event-handler" }, + withCapture( + logger, + { + feature: "${a.feature}", + layer: "event-handler", + name: "${a.feature}.${handlerFn}", + }, + ${handlerFn}(), + ), + ); + if (${containerVar}.isBound(${handlerSymbol})) { + ${containerVar}.unbind(${handlerSymbol}); + } + ${containerVar}.bind(${handlerSymbol}).toConstantValue(${wrappedVar}); + bus.subscribe(${eventConst}, "${a.feature}", ${wrappedVar});`; +} + +function printConsumeNextSteps(a: { + feature: string; + event: string; + publisher: string; +}): string { + const eventKebab = a.event.replace(/\./g, "-"); + const eventConst = `${camel(a.event.replace(/\./g, "-"))}Event`; + return [ + "", + "─────────────────────────────────────────────────────────────", + `Handler on-${a.publisher}-${eventKebab} scaffolded in ${a.feature}.`, + "", + "Next steps (manual):", + ` 1. Implement the handler body in packages/${a.feature}/src/events/handlers/on-${a.publisher}-${eventKebab}.handler.ts`, + ` 2. Add the import at the top of bind-production.ts AND bind-dev-seed.ts:`, + ` import { ${eventConst} } from "@repo/${a.publisher}";`, + ` import { ${camel(`on-${a.publisher}-${a.event.replace(/\./g, "-")}-handler`)} } from "../events/handlers/on-${a.publisher}-${eventKebab}.handler";`, + ` 3. If your handler needs deps, extend the factory signature and pass them in the generated bind block.`, + ` 4. Add "@repo/${a.publisher}": "workspace:*" to packages/${a.feature}/package.json dependencies (if not already present).`, + ` 5. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, + "─────────────────────────────────────────────────────────────", + "", + ].join("\n"); +} + function printPublishNextSteps(a: { feature: string; event: string }): string { const eventKebab = a.event.replace(/\./g, "-"); return [ @@ -475,3 +600,14 @@ function camel(input: string): string { const p = cap(input); return p ? p[0]!.toLowerCase() + p.slice(1) : ""; } +function pascalCase(input: string): string { + // Same as `cap` but also splits on dots, so "user.signed-up" → "UserSignedUp". + return input + .split(/[-_.\s]+/) + .filter(Boolean) + .map((s) => s[0]!.toUpperCase() + s.slice(1)) + .join(""); +} +function constantCase(input: string): string { + return input.toUpperCase().replace(/[-.\s]/g, "_"); +}