diff --git a/packages/core-shared/src/conformance/brand-runtime.ts b/packages/core-shared/src/conformance/brand-runtime.ts index bb2b3e5..7615dc8 100644 --- a/packages/core-shared/src/conformance/brand-runtime.ts +++ b/packages/core-shared/src/conformance/brand-runtime.ts @@ -13,7 +13,7 @@ * commitment, not a mutable flag. */ -import type { Analyzed, ConsentChecked, RateLimited } from "./brands"; +import type { Analyzed, ConsentChecked, RateLimited, ReadOnly } from "./brands"; type Brand = | "__instrumented" @@ -21,7 +21,8 @@ type Brand = | "__audited" | "__analyzed" | "__consentChecked" - | "__rateLimited"; + | "__rateLimited" + | "__readonly"; /** * Attaches the brand as a non-enumerable property on the given function. @@ -75,3 +76,7 @@ export function isRateLimited( ): fn is RateLimited { return hasBrand(fn, "__rateLimited"); } + +export function isReadOnly(fn: unknown): fn is ReadOnly { + return hasBrand(fn, "__readonly"); +} diff --git a/packages/core-shared/src/conformance/brands.ts b/packages/core-shared/src/conformance/brands.ts index 76b5f8b..e141215 100644 --- a/packages/core-shared/src/conformance/brands.ts +++ b/packages/core-shared/src/conformance/brands.ts @@ -12,3 +12,9 @@ export type Captured = F & { readonly __captured: true }; export type Analyzed = F & { readonly __analyzed: true }; export type ConsentChecked = F & { readonly __consentChecked: true }; export type RateLimited = F & { readonly __rateLimited: true }; +/** + * Brand for use cases declared `mutates: false`. Readers only accept + * `ReadOnly`-branded use cases in their constructor — prevents mutating + * use cases from being wired into a reader at compile time. + */ +export type ReadOnly = F & { readonly __readonly: true }; diff --git a/packages/core-shared/src/conformance/index.ts b/packages/core-shared/src/conformance/index.ts index 3f38af3..fc98683 100644 --- a/packages/core-shared/src/conformance/index.ts +++ b/packages/core-shared/src/conformance/index.ts @@ -4,6 +4,7 @@ export type { Analyzed, ConsentChecked, RateLimited, + ReadOnly, } from "./brands"; export type { FeatureManifest, UseCaseManifest } from "./define-feature"; export { defineFeature } from "./define-feature"; @@ -31,6 +32,7 @@ export { isAnalyzed, isConsentChecked, isRateLimited, + isReadOnly, } from "./brand-runtime"; export { ConformanceError } from "./conformance-error"; export { assertFeatureConformance } from "./assert-bindings"; diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 933d168..f1e89d5 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -476,6 +476,50 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { }, }); + /** + * Turbo generator: `reader` + * + * Scaffolds a cross-feature reader under + * `packages//src/integrations/readers/` and adds the `./reader` + * export subpath to the feature's `package.json`. The reader interface is + * the public contract; the implementation and test are internal. + * + * See ADR-026 for the full design (rules Q0–Q3). + */ + plop.setGenerator("reader", { + description: + "Scaffold a cross-feature reader interface + implementation (ADR-026)", + prompts: [ + { + type: "input", + name: "feature", + message: "Feature that will EXPOSE the reader (kebab-case):", + validate(input: string) { + if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case"; + if (!existsSync(join(process.cwd(), "packages", input, "src"))) { + return `packages/${input}/src does not exist`; + } + const readersDir = join( + process.cwd(), + "packages", + input, + "src", + "integrations", + "readers", + ); + if (existsSync(readersDir)) { + return `packages/${input}/src/integrations/readers/ already exists — this feature already has a reader`; + } + return true; + }, + }, + ], + actions(answers) { + const a = answers as { feature: string }; + return readerActions(a); + }, + }); + /** * Turbo generator: `realtime` * @@ -1034,6 +1078,82 @@ function jobBindBlock(a: { feature: string; job: string }): string { ${containerVar}.bind(${symbol}).toConstantValue(wrapped${pascalCase(a.job)});`; } +function readerActions(a: { feature: string }): PlopTypes.ActionType[] { + const base = `packages/${a.feature}/src/integrations/readers`; + const pkgJsonPath = `packages/${a.feature}/package.json`; + return [ + { + type: "add", + path: `${base}/${a.feature}.reader.interface.ts`, + templateFile: "templates/reader/reader.interface.ts.hbs", + data: a, + }, + { + type: "add", + path: `${base}/${a.feature}.reader.ts`, + templateFile: "templates/reader/reader.ts.hbs", + data: a, + }, + { + type: "add", + path: `${base}/${a.feature}.reader.test.ts`, + templateFile: "templates/reader/reader.test.ts.hbs", + data: a, + }, + { + type: "add", + path: `${base}/index.ts`, + templateFile: "templates/reader/index.ts.hbs", + data: a, + }, + // Add ./reader subpath to package.json exports map + () => { + const fs = require("node:fs"); + const pkgPath = join(process.cwd(), pkgJsonPath); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + if (!pkg.exports) pkg.exports = {}; + pkg.exports["./reader"] = "./src/integrations/readers/index.ts"; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + return `Added "./reader" export to ${pkgJsonPath}`; + }, + () => printReaderNextSteps(a), + ]; +} + +function printReaderNextSteps(a: { feature: string }): string { + const pascal = pascalCase(a.feature); + return [ + "", + "─────────────────────────────────────────────────────────────", + ` Reader scaffolded for @repo/${a.feature}`, + "─────────────────────────────────────────────────────────────", + "", + " Next steps:", + "", + ` 1. Add methods to I${pascal}Reader interface:`, + ` packages/${a.feature}/src/integrations/readers/${a.feature}.reader.interface.ts`, + "", + ` 2. Implement methods in ${pascal}Reader (delegate to use cases):`, + ` packages/${a.feature}/src/integrations/readers/${a.feature}.reader.ts`, + "", + ` 3. Construct the reader in bind-production.ts and return it:`, + ` const reader = new ${pascal}Reader(/* injected use cases */);`, + ` return { reader };`, + "", + ` 4. In bindAll(), pass the reader to consuming features:`, + ` const ${a.feature}Result = bindProduction${pascal}(ctx);`, + ` bindProductionConsumer(ctx, { ${a.feature}Reader: ${a.feature}Result.reader });`, + "", + ` 5. In consuming features' manifest, declare: reads: ["${a.feature}"]`, + "", + " Rules: Q0 (cross-feature only), Q1 (interface public, impl private),", + " Q2 (read-only — wraps only mutates:false use cases),", + " Q3 (reader cycles are a design error)", + "", + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} + function printJobNextSteps(a: { feature: string; job: string }): string { return [ "", diff --git a/turbo/generators/templates/reader/index.ts.hbs b/turbo/generators/templates/reader/index.ts.hbs new file mode 100644 index 0000000..173d424 --- /dev/null +++ b/turbo/generators/templates/reader/index.ts.hbs @@ -0,0 +1,3 @@ +// packages/{{kebabCase feature}}/src/integrations/readers/index.ts +// Public surface: type-only export. Implementation is internal. +export type { I{{pascalCase feature}}Reader } from "./{{kebabCase feature}}.reader.interface"; diff --git a/turbo/generators/templates/reader/reader.interface.ts.hbs b/turbo/generators/templates/reader/reader.interface.ts.hbs new file mode 100644 index 0000000..022d3f9 --- /dev/null +++ b/turbo/generators/templates/reader/reader.interface.ts.hbs @@ -0,0 +1,14 @@ +// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.interface.ts + +/** + * Cross-feature read-only query contract for the {{kebabCase feature}} vertical. + * Consumers import this type from `@repo/{{kebabCase feature}}/reader`. + * Implementation is private — constructed by the feature's binder. + * + * Rules: Q0 (cross-feature domain queries only), Q1 (interface public, + * impl private), Q2 (read-only — wraps only mutates:false use cases). + */ +export interface I{{pascalCase feature}}Reader { + // Add methods as consumers need them. Each method must delegate to a + // use case declared mutates: false in feature.manifest.ts. +} diff --git a/turbo/generators/templates/reader/reader.test.ts.hbs b/turbo/generators/templates/reader/reader.test.ts.hbs new file mode 100644 index 0000000..942be15 --- /dev/null +++ b/turbo/generators/templates/reader/reader.test.ts.hbs @@ -0,0 +1,10 @@ +// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.test.ts +import { describe, it, expect } from "vitest"; +import { {{pascalCase feature}}Reader } from "@/integrations/readers/{{kebabCase feature}}.reader"; + +describe("{{pascalCase feature}}Reader", () => { + it("can be constructed", () => { + const reader = new {{pascalCase feature}}Reader(); + expect(reader).toBeDefined(); + }); +}); diff --git a/turbo/generators/templates/reader/reader.ts.hbs b/turbo/generators/templates/reader/reader.ts.hbs new file mode 100644 index 0000000..46253e1 --- /dev/null +++ b/turbo/generators/templates/reader/reader.ts.hbs @@ -0,0 +1,25 @@ +// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.ts + +import type { I{{pascalCase feature}}Reader } from "./{{kebabCase feature}}.reader.interface"; + +/** + * Internal implementation of I{{pascalCase feature}}Reader. + * Wraps existing use cases — does not add domain logic. + * Constructed by bind-production / bind-dev-seed; never exported. + * + * All injected use cases MUST be ReadOnly-branded (mutates: false). + */ +export class {{pascalCase feature}}Reader implements I{{pascalCase feature}}Reader { + // Inject ReadOnly-branded use cases via constructor: + // + // constructor( + // private getUser: ReadOnly, + // ) {} + // + // Then delegate: + // + // async exists(userId: string): Promise { + // const user = await this.getUser({ id: userId }); + // return user !== null; + // } +}