diff --git a/turbo/generators/__tests__/core-package-events.e2e.test.ts b/turbo/generators/__tests__/core-package-events.e2e.test.ts new file mode 100644 index 0000000..e5b7c1e --- /dev/null +++ b/turbo/generators/__tests__/core-package-events.e2e.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, cpSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { computeSnapshot } from "../lib/snapshot"; +import expectedSnapshot from "../__snapshots__/core-package/events.snapshot.json"; + +// Repo root is 2 levels up from turbo/generators/__tests__ +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..", "..", ".."); + +/** + * Strip "@repo/core-events" from a package.json file in the tmp tree. + * Required when simulating a fresh scaffold: core-events does not exist yet + * but the snapshot was captured before removal. Feature packages in the + * current tree may still list it as a dependency. + */ +function stripCoreEventsDep(pkgJsonPath: string): void { + const raw = readFileSync(pkgJsonPath, "utf8"); + const parsed = JSON.parse(raw) as Record>; + for (const section of ["dependencies", "devDependencies", "peerDependencies"] as const) { + if (parsed[section]?.["@repo/core-events"]) { + delete parsed[section]["@repo/core-events"]; + } + } + writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n"); +} + +describe("e2e: core-package events", () => { + it("byte-identical reconstruction matches snapshot", { timeout: 120_000 }, () => { + const tmp = mkdtempSync(join(tmpdir(), "e2e-events-")); + cpSync(REPO_ROOT, tmp, { + recursive: true, + filter: (src) => + !src.includes("node_modules") && + !src.includes(".turbo") && + !src.includes("packages/core-events"), + }); + + // Strip @repo/core-events from feature package.json files so pnpm install + // succeeds without the package being present (simulating the post-removal state). + const featurePackages = ["auth", "blog", "media", "marketing-pages", "navigation"]; + for (const pkg of featurePackages) { + const pkgJson = join(tmp, "packages", pkg, "package.json"); + stripCoreEventsDep(pkgJson); + } + // Also strip from apps/web-next + stripCoreEventsDep(join(tmp, "apps", "web-next", "package.json")); + + execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, { stdio: "ignore" }); + execSync(`cd ${tmp} && pnpm turbo gen core-package --args events`, { stdio: "ignore" }); + const result = computeSnapshot(join(tmp, "packages/core-events")); + expect(result).toEqual(expectedSnapshot); + }); +}); diff --git a/turbo/generators/config.test.ts b/turbo/generators/config.test.ts index 545527b..45a0e3e 100644 --- a/turbo/generators/config.test.ts +++ b/turbo/generators/config.test.ts @@ -3,7 +3,7 @@ import type { PlopTypes } from "@turbo/gen"; import generator from "./config"; describe("core-package generator", () => { - it("is registered with realtime in choices list", () => { + it("is registered with realtime and events in choices list", () => { const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = []; const plopMock = { setHelper: () => {}, @@ -16,6 +16,7 @@ describe("core-package generator", () => { const prompts = corePkg!.def.prompts as Array<{ name: string; choices: unknown[] }>; expect(prompts[0]!.name).toBe("name"); expect(prompts[0]!.choices).toContain("realtime"); + expect(prompts[0]!.choices).toContain("events"); }); }); @@ -36,3 +37,20 @@ describe("core-package realtime", () => { expect(actions.length).toBeGreaterThan(20); // 28 files + extras }); }); + +describe("core-package events", () => { + it("emits actions covering package files, transpilePackages, and ESLint rule splice", () => { + const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = []; + const plop = { + setHelper: () => {}, + setGenerator: (n: string, d: unknown) => captured.push({ name: n, def: d as PlopTypes.PlopGeneratorConfig }), + } as unknown as PlopTypes.NodePlopAPI; + generator(plop); + const corePkg = captured.find((c) => c.name === "core-package")!.def; + const actions = (corePkg.actions as (a: { name: string }) => PlopTypes.ActionType[])( + { name: "events" }, + ); + // 1 guard + 15 template files + transpilePackages + ESLint splice + printNextSteps = 19 + expect(actions.length).toBeGreaterThanOrEqual(18); + }); +}); diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index d39195d..4803dbd 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -525,6 +525,65 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js"; }, },`; + const EVENTS_RULE_BLOCK = ` { + files: ["**/*.{ts,tsx,mjs,cjs,js}"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: + "ExportNamedDeclaration[source.value=/\\/events\\/handlers\\//]", + message: + "Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).", + }, + { + selector: + "ExportAllDeclaration[source.value=/\\/events\\/handlers\\//]", + message: + "Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).", + }, + { + selector: + "MemberExpression[object.type='MemberExpression'][object.object.type='Identifier'][object.object.name='payload'][object.property.type='Identifier'][object.property.name='jobs']", + message: + "Direct \`payload.jobs.*\` access is not allowed here. Use IJobQueue (from @repo/core-shared/jobs) instead. Allowed only in **/integrations/cms/jobs/** and **/core-shared/src/jobs/**.", + }, + ], + }, + }, + // J — \`payload.jobs.*\` is allowed only in the integration layer. + // In these paths, no-restricted-syntax is narrowed to keep E1 active but + // drop the payload.jobs check. + // Note: "**/core-shared/src/jobs/**" does not match from within a package-local + // ESLint run because ESLint resolves globs relative to the config file location. + // The pattern is kept for documentation; in practice, the PayloadJobQueue class + // uses \`this.payload.jobs.*\` which the selector already ignores (it only catches + // bare \`payload.jobs.*\`). Any new file added there that does use bare \`payload.jobs.*\` + // would need this allowlist to be expressed as "**/jobs/payload-*" or similar. + { + files: [ + "**/integrations/cms/jobs/**", + "**/core-shared/src/jobs/**", + ], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: + "ExportNamedDeclaration[source.value=/\\/events\\/handlers\\//]", + message: + "Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).", + }, + { + selector: + "ExportAllDeclaration[source.value=/\\/events\\/handlers\\//]", + message: + "Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).", + }, + ], + }, + },`; + const CORE_PACKAGE_GENERATORS: Record PlopTypes.ActionType[]> = { realtime: () => [ @@ -563,6 +622,26 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js"; }, printRealtimeNextSteps, ], + events: () => [ + () => { + assertOptionalPackageNotPresent("core-events"); + return "Guard passed — packages/core-events does not exist yet."; + }, + ...emitTemplateTree("core-package/events", "packages/core-events"), + () => { + addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-events"); + return "Added @repo/core-events to transpilePackages."; + }, + () => { + splicePluginRulesAt( + "packages/core-eslint/base.js", + "events-rules", + EVENTS_RULE_BLOCK, + ); + return "Added events rule block to base.js."; + }, + printEventsNextSteps, + ], }; plop.setGenerator("core-package", { @@ -572,7 +651,7 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js"; type: "list", name: "name", message: "Which optional core package?", - choices: ["realtime"], + choices: ["realtime", "events"], }, ], actions: (answers) => { @@ -1067,6 +1146,28 @@ function printHandlerNextSteps(a: { ].join("\n"); } +function printEventsNextSteps(): string { + return [ + "─────────────────────────────────────────────────────────────", + "@repo/core-events scaffolded.", + "", + "Next steps (manual wiring — not generated):", + "", + " 1. pnpm install # link the new workspace package", + "", + " 2. apps/web-next/src/server/bind-production.ts:", + ' - import { InMemoryEventBus, PayloadJobsEventBus, type IEventBus } from "@repo/core-events";', + " - construct bus in resolveEventsAndJobsProduction + resolveEventsAndJobsDevSeed", + " - add bus to BindProductionContext generic arg and ctx object", + " - update BindAllDeps to include bus field", + "", + " 3. Add @repo/core-events to each feature package.json that publishes or subscribes events", + "", + " 4. pnpm typecheck && pnpm lint && pnpm test", + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} + function printRealtimeNextSteps(): string { return [ "─────────────────────────────────────────────────────────────",