feat(conformance): implement ReadOnly brand and reader generator
- Add ReadOnly<F> phantom brand to core-shared/conformance (compile-time enforcement that readers only wrap non-mutating use cases) - Add isReadOnly runtime predicate for boot-time assertReaderPurity - Scaffold pnpm turbo gen reader: creates integrations/readers/ with interface, implementation, test, barrel, and adds ./reader export subpath to package.json
This commit is contained in:
@@ -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<F extends object>(
|
||||
): fn is RateLimited<F> {
|
||||
return hasBrand(fn, "__rateLimited");
|
||||
}
|
||||
|
||||
export function isReadOnly<F extends object>(fn: unknown): fn is ReadOnly<F> {
|
||||
return hasBrand(fn, "__readonly");
|
||||
}
|
||||
|
||||
@@ -12,3 +12,9 @@ export type Captured<F> = F & { readonly __captured: true };
|
||||
export type Analyzed<F> = F & { readonly __analyzed: true };
|
||||
export type ConsentChecked<F> = F & { readonly __consentChecked: true };
|
||||
export type RateLimited<F> = 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> = F & { readonly __readonly: true };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -476,6 +476,50 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Turbo generator: `reader`
|
||||
*
|
||||
* Scaffolds a cross-feature reader under
|
||||
* `packages/<feature>/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 [
|
||||
"",
|
||||
|
||||
3
turbo/generators/templates/reader/index.ts.hbs
Normal file
3
turbo/generators/templates/reader/index.ts.hbs
Normal file
@@ -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";
|
||||
14
turbo/generators/templates/reader/reader.interface.ts.hbs
Normal file
14
turbo/generators/templates/reader/reader.interface.ts.hbs
Normal file
@@ -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.
|
||||
}
|
||||
10
turbo/generators/templates/reader/reader.test.ts.hbs
Normal file
10
turbo/generators/templates/reader/reader.test.ts.hbs
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
25
turbo/generators/templates/reader/reader.ts.hbs
Normal file
25
turbo/generators/templates/reader/reader.ts.hbs
Normal file
@@ -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<IGetUserUseCase>,
|
||||
// ) {}
|
||||
//
|
||||
// Then delegate:
|
||||
//
|
||||
// async exists(userId: string): Promise<boolean> {
|
||||
// const user = await this.getUser({ id: userId });
|
||||
// return user !== null;
|
||||
// }
|
||||
}
|
||||
Reference in New Issue
Block a user