@fougere/cli 0.6.0-alpha.0 → 0.8.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/commands/BuildCommand.ts +1 -1
- package/app/commands/CallCommand.ts +3 -3
- package/app/commands/CheckCommand.ts +1 -1
- package/app/commands/DevtoolsCommand.ts +1 -1
- package/app/commands/ExplainCommand.ts +1 -1
- package/app/commands/FreezeCommand.ts +2 -2
- package/app/commands/GraphCommand.ts +1 -1
- package/app/commands/MigrateCommand.ts +1 -1
- package/app/commands/NewCommand.ts +5 -3
- package/dist/bin.js +3 -14
- package/dist/bin.js.map +1 -1
- package/dist/bridge.d.ts +1 -6
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +38 -39
- package/dist/bridge.js.map +1 -1
- package/dist/completion.d.ts +1 -8
- package/dist/completion.d.ts.map +1 -1
- package/dist/completion.js.map +1 -1
- package/dist/loader.d.ts +1 -8
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +1 -8
- package/dist/loader.js.map +1 -1
- package/dist/machine.d.ts +4 -10
- package/dist/machine.d.ts.map +1 -1
- package/dist/machine.js +4 -10
- package/dist/machine.js.map +1 -1
- package/dist/runner.d.ts +2 -7
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +4 -4
- package/dist/runner.js.map +1 -1
- package/dist/typescript/EntityTypes.d.ts +12 -0
- package/dist/typescript/EntityTypes.d.ts.map +1 -0
- package/dist/typescript/EntityTypes.js +76 -0
- package/dist/typescript/EntityTypes.js.map +1 -0
- package/dist/typescript/FacadeTypes.d.ts +19 -0
- package/dist/typescript/FacadeTypes.d.ts.map +1 -0
- package/dist/typescript/FacadeTypes.js +34 -0
- package/dist/typescript/FacadeTypes.js.map +1 -0
- package/dist/typescript/syntax.d.ts +5 -0
- package/dist/typescript/syntax.d.ts.map +1 -0
- package/dist/typescript/syntax.js +11 -0
- package/dist/typescript/syntax.js.map +1 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +1 -11
- package/dist/ui.js.map +1 -1
- package/fronds/analysis/handlers/CheckHandler.ts +61 -2
- package/fronds/analysis/handlers/DevtoolsHandler.ts +1 -1
- package/fronds/analysis/handlers/ExplainHandler.ts +2 -2
- package/fronds/analysis/handlers/MigrateHandler.ts +3 -1
- package/fronds/scaffold/handlers/SyncHandler.ts +9 -7
- package/fronds/scaffold/services/ProjectWriter.ts +43 -0
- package/fronds/shims.d.ts +9 -0
- package/package.json +8 -7
- package/src/bin.ts +3 -14
- package/src/bridge.ts +52 -41
- package/src/completion.ts +1 -8
- package/src/loader.ts +1 -8
- package/src/machine.ts +4 -10
- package/src/runner.ts +7 -12
- package/src/typescript/EntityTypes.ts +86 -0
- package/src/typescript/FacadeTypes.ts +48 -0
- package/src/typescript/syntax.ts +11 -0
- package/src/ui.ts +1 -11
- package/templates/admin/fronds/admin/handlers/UserHandler.ts +1 -1
- package/templates/api/fronds/api/handlers/TaskHandler.ts +1 -1
- package/templates/blog/app/pages/index.vue +1 -1
- package/templates/blog/app/pages/posts/manage.vue +2 -2
- package/templates/blog/fronds/blog/handlers/PostHandler.ts +2 -2
- package/templates/flat/CLAUDE.md +4 -4
- package/templates/frond/CLAUDE.md +4 -4
- package/templates/fronds/blank/handlers/ItemHandler.ts +1 -1
- package/templates/workspace/CLAUDE.md +4 -4
package/src/runner.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI runner — scans frond entities for flags, looks for app commands
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Architecture:
|
|
6
|
-
* - fronds/ → entities (flags) + handlers (domain logic)
|
|
7
|
-
* - app/ → commands (prompts, TUI, presentation)
|
|
8
|
-
* - src/ → runner + bridge (framework)
|
|
2
|
+
* CLI runner — scans frond entities for flags, looks for app commands for presentation, dispatches
|
|
3
|
+
* via citty.
|
|
9
4
|
*/
|
|
10
5
|
import type { App } from '@fougere/core';
|
|
11
6
|
import { createAppRunner } from '@fougere/core';
|
|
@@ -83,7 +78,7 @@ export async function run(app: App): Promise<void> {
|
|
|
83
78
|
// App commands handle their own prompting — don't let citty reject missing args
|
|
84
79
|
if (AppCommand) {
|
|
85
80
|
for (const def of Object.values(args)) {
|
|
86
|
-
if (typeof def === 'object' && def)
|
|
81
|
+
if (typeof def === 'object' && def) def.required = false;
|
|
87
82
|
}
|
|
88
83
|
}
|
|
89
84
|
|
|
@@ -121,15 +116,15 @@ export async function run(app: App): Promise<void> {
|
|
|
121
116
|
// Ride the call contract — the same envelope every consumer uses.
|
|
122
117
|
await createAppRunner(app)(
|
|
123
118
|
{ entity: lowerFirst(entity.name), op: 'execute' },
|
|
124
|
-
{ params: {}, query: {},
|
|
119
|
+
{ params: {}, query: {}, input: input, state: {} },
|
|
125
120
|
);
|
|
126
121
|
}
|
|
127
122
|
} catch (err) {
|
|
128
|
-
const
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
129
124
|
// A machine reader parses stdout: a refusal printed there is a refusal that
|
|
130
125
|
// breaks the parse instead of being read. stderr is where it belongs.
|
|
131
|
-
if (machineOutput) process.stderr.write(
|
|
132
|
-
else terminal.error(
|
|
126
|
+
if (machineOutput) process.stderr.write(message + '\n');
|
|
127
|
+
else terminal.error(message);
|
|
133
128
|
process.exit(1);
|
|
134
129
|
}
|
|
135
130
|
},
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { upperFirst, type FieldDescriptor, type SchemaDescriptor } from '@fougere/schema';
|
|
2
|
+
import { docCommentOf, propertyKey } from './syntax.js';
|
|
3
|
+
|
|
4
|
+
/** So a nullable field lands as a union. */
|
|
5
|
+
function typeOf(field: FieldDescriptor): string {
|
|
6
|
+
const types = Array.isArray(field.type) ? field.type : field.type ? [field.type] : [];
|
|
7
|
+
const nullable = types.includes('null');
|
|
8
|
+
const base = types.find((t) => t !== 'null');
|
|
9
|
+
const inner = baseTypeOf(base, field);
|
|
10
|
+
return nullable ? `${inner} | null` : inner;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** So `date-time` becomes a `Date`, the same thing the boundary decodes to. */
|
|
14
|
+
function baseTypeOf(base: string | undefined, field: FieldDescriptor): string {
|
|
15
|
+
if (field.enum?.length) {
|
|
16
|
+
return field.enum.map((v) => (v === null ? 'null' : JSON.stringify(v))).join(' | ');
|
|
17
|
+
}
|
|
18
|
+
switch (base) {
|
|
19
|
+
case 'string':
|
|
20
|
+
return field.format === 'date-time' ? 'Date' : 'string';
|
|
21
|
+
case 'number':
|
|
22
|
+
case 'integer':
|
|
23
|
+
return 'number';
|
|
24
|
+
case 'boolean':
|
|
25
|
+
return 'boolean';
|
|
26
|
+
case 'array':
|
|
27
|
+
return field.items ? `${typeOf(field.items)}[]` : 'string[]';
|
|
28
|
+
case 'object':
|
|
29
|
+
return field.properties ? objectTypeOf(field.properties, field.required ?? []) : 'Record<string, unknown>';
|
|
30
|
+
default:
|
|
31
|
+
return 'unknown';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** So a nested object keeps its optionality. */
|
|
36
|
+
function objectTypeOf(properties: Record<string, FieldDescriptor>, required: readonly string[]): string {
|
|
37
|
+
const members = Object.entries(properties).map(([name, field]) => {
|
|
38
|
+
const optional = required.includes(name) ? '' : '?';
|
|
39
|
+
return `${propertyKey(name)}${optional}: ${typeOf(field)}`;
|
|
40
|
+
});
|
|
41
|
+
return `{ ${members.join('; ')} }`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EntityTypesOptions {
|
|
45
|
+
name?: string;
|
|
46
|
+
exported?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** So the generated class carries its row type. */
|
|
50
|
+
function shapeTypeOf(descriptor: SchemaDescriptor, indent = ''): string {
|
|
51
|
+
const entries = Object.entries(descriptor.properties ?? {});
|
|
52
|
+
if (entries.length === 0) return '{}';
|
|
53
|
+
|
|
54
|
+
const lines = entries.map(([key, field]) => {
|
|
55
|
+
const doc = docCommentOf(field.description, `${indent} `);
|
|
56
|
+
return `${doc}${indent} ${propertyKey(key)}: ${typeOf(field)};`;
|
|
57
|
+
});
|
|
58
|
+
return `{\n${lines.join('\n')}\n${indent}}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class EntityTypes {
|
|
62
|
+
private constructor(private readonly descriptor: SchemaDescriptor) {}
|
|
63
|
+
|
|
64
|
+
static of(descriptor: SchemaDescriptor): EntityTypes {
|
|
65
|
+
return new EntityTypes(descriptor);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
render(options: EntityTypesOptions = {}): string {
|
|
69
|
+
const name = identifierOf(options.name ?? upperFirst(this.descriptor.title ?? 'Schema'));
|
|
70
|
+
const exported = options.exported === false ? '' : 'export ';
|
|
71
|
+
const card = JSON.stringify(this.descriptor, null, 2)
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
|
74
|
+
.join('\n');
|
|
75
|
+
|
|
76
|
+
return `${exported}class ${name} extends Card.fromDescriptor<${shapeTypeOf(this.descriptor)}>(${card}).toSchema() {}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** So a name that cannot declare a class is refused before it reaches a file. */
|
|
81
|
+
function identifierOf(name: string): string {
|
|
82
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
|
|
83
|
+
throw new Error(`'${name}' is not a TypeScript identifier — it cannot name a generated declaration`);
|
|
84
|
+
}
|
|
85
|
+
return name;
|
|
86
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SchemaDescriptor } from '@fougere/schema';
|
|
2
|
+
import { docCommentOf, propertyKey } from './syntax.js';
|
|
3
|
+
|
|
4
|
+
export interface FacadeTypesOptions {
|
|
5
|
+
name?: string;
|
|
6
|
+
exported?: boolean;
|
|
7
|
+
rowType?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OpDescriptor {
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
output?: SchemaDescriptor;
|
|
14
|
+
cardinality?: 'one' | 'maybe' | 'many' | 'page' | 'none';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** So a consumer sees the cardinality in the type, not in a doc line. */
|
|
18
|
+
function returnTypeOf(op: OpDescriptor, rowType: string): string {
|
|
19
|
+
switch (op.cardinality) {
|
|
20
|
+
case 'many': return `${rowType}[]`;
|
|
21
|
+
case 'page': return `${rowType}[] & { total?: number; endCursor?: string; hasMore?: boolean }`;
|
|
22
|
+
case 'maybe': return `${rowType} | undefined`;
|
|
23
|
+
case 'one': return rowType;
|
|
24
|
+
case 'none': return 'unknown';
|
|
25
|
+
default: return 'unknown';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class FacadeTypes {
|
|
30
|
+
private constructor(private readonly operations: readonly OpDescriptor[]) {}
|
|
31
|
+
|
|
32
|
+
static of(operations: readonly OpDescriptor[]): FacadeTypes {
|
|
33
|
+
return new FacadeTypes(operations);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
render(options: FacadeTypesOptions = {}): string {
|
|
37
|
+
const name = options.name ?? 'Facade';
|
|
38
|
+
const exported = options.exported === false ? '' : 'export ';
|
|
39
|
+
const rowType = options.rowType ?? 'unknown';
|
|
40
|
+
const members = this.operations.map((operation) => {
|
|
41
|
+
const doc = docCommentOf(operation.description, ' ');
|
|
42
|
+
return `${doc} ${propertyKey(operation.name)}(invocation?: Invocation): Promise<${returnTypeOf(operation, rowType)}>;`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (members.length === 0) return `${exported}interface ${name} {}`;
|
|
46
|
+
return `${exported}interface ${name} {\n${members.join('\n')}\n}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** So a name that is not an identifier is still written as a key. */
|
|
2
|
+
export function propertyKey(name: string): string {
|
|
3
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** So a sentence shows on hover — and cannot close the comment it sits in. */
|
|
7
|
+
export function docCommentOf(text: string | undefined, indent: string): string {
|
|
8
|
+
if (!text) return '';
|
|
9
|
+
|
|
10
|
+
return `${indent}/** ${text.replace(/\*\//g, '*\\/')} */\n`;
|
|
11
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fougere CLI UI — beautiful terminal interface.
|
|
3
|
-
*
|
|
4
|
-
* Wraps @clack/prompts + picocolors + consola into a cohesive API.
|
|
5
|
-
*
|
|
6
|
-
* This was `@fougere/cli-ui`, a published package with exactly one consumer —
|
|
7
|
-
* the CLI it is named after. A second name in the registry that nobody would
|
|
8
|
-
* ever install on purpose is a name, not a boundary. Same dependency profile,
|
|
9
|
-
* so it folds in as a module; a subpath export is one line the day something
|
|
10
|
-
* outside the CLI wants it.
|
|
11
|
-
*/
|
|
1
|
+
/** Fougere CLI UI — beautiful terminal interface. */
|
|
12
2
|
import * as clack from '@clack/prompts';
|
|
13
3
|
import pc from 'picocolors';
|
|
14
4
|
import { consola } from 'consola';
|
|
@@ -7,7 +7,7 @@ export class UserCard extends User.pick('id', 'name', 'status') {}
|
|
|
7
7
|
// Crud(User) gives list/create/update/delete for free — the accelerator.
|
|
8
8
|
// 'deactivate' is the business contract: a state transition, not a field write.
|
|
9
9
|
export default class UserHandler extends Crud(User) {
|
|
10
|
-
/** active→inactive — an operation, not a field write.
|
|
10
|
+
/** active→inactive — an operation, not a field write. Validate: active only. */
|
|
11
11
|
async deactivate(id: string): Promise<User> {
|
|
12
12
|
const user = await this.storage.findById(id);
|
|
13
13
|
if (!user) {
|
|
@@ -7,7 +7,7 @@ export class TaskCard extends Task.pick('id', 'title', 'status') {}
|
|
|
7
7
|
// Crud(Task) gives list/create/update/delete for free — the accelerator.
|
|
8
8
|
// 'complete' is the business contract: a state transition, not a field write.
|
|
9
9
|
export default class TaskHandler extends Crud(Task) {
|
|
10
|
-
/** open→done — an operation, not a field write.
|
|
10
|
+
/** open→done — an operation, not a field write. Validate: open only. */
|
|
11
11
|
async complete(id: string): Promise<Task> {
|
|
12
12
|
const task = await this.storage.findById(id);
|
|
13
13
|
if (!task) {
|
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
<NuxtLink to="/posts/manage">Drafts & publishing</NuxtLink> ·
|
|
8
8
|
<NuxtLink to="/posts/new">New draft</NuxtLink>
|
|
9
9
|
</p>
|
|
10
|
-
<p class="muted">The loop: create a draft → it stays private → publish it (a
|
|
10
|
+
<p class="muted">The loop: create a draft → it stays private → publish it (a validated operation, not a field write) → it appears in the published list, live.</p>
|
|
11
11
|
</main>
|
|
12
12
|
</template>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import Post from '@fronds/blog/entities/Post';
|
|
3
3
|
|
|
4
|
-
interface
|
|
5
|
-
const { items: posts, loading } = await useQuery<
|
|
4
|
+
interface PostValues { id: string; title: string; status: 'draft' | 'published' }
|
|
5
|
+
const { items: posts, loading } = await useQuery<PostValues>(Post, 'list');
|
|
6
6
|
const publish = useCommand(Post, 'publish');
|
|
7
7
|
const failed = ref('');
|
|
8
8
|
|
|
@@ -7,11 +7,11 @@ import Post from '../entities/Post.js';
|
|
|
7
7
|
export class PostCard extends Post.pick('id', 'title', 'status') {}
|
|
8
8
|
|
|
9
9
|
// Crud(Post) gives list/create/update/delete for free — the accelerator.
|
|
10
|
-
// 'publish' is the real business contract: a state transition that
|
|
10
|
+
// 'publish' is the real business contract: a state transition that validates
|
|
11
11
|
// before it realises — an operation, not a field write. The golden path.
|
|
12
12
|
export default class PostHandler extends Crud(Post) {
|
|
13
13
|
/**
|
|
14
|
-
* The draft→published transition.
|
|
14
|
+
* The draft→published transition. Validate: exists, draft only.
|
|
15
15
|
* Realise: the server flips the owned field.
|
|
16
16
|
*/
|
|
17
17
|
async publish(id: string): Promise<Post> {
|
package/templates/flat/CLAUDE.md
CHANGED
|
@@ -21,15 +21,15 @@ Two consequences worth stating, because they are what makes it hold:
|
|
|
21
21
|
a parallel entity that repeats the same fields. A field added to the entity is then accepted
|
|
22
22
|
without touching the view.
|
|
23
23
|
- **A handler writes what the input carries** (`{ ...attributes }`), it does not enumerate its
|
|
24
|
-
fields — otherwise a new field is
|
|
24
|
+
fields — otherwise a new field is validated, then silently not written.
|
|
25
25
|
|
|
26
26
|
If you are about to write the same constraint in two places, you have missed the derivation.
|
|
27
27
|
|
|
28
28
|
## A surface is a door, never a logic
|
|
29
29
|
|
|
30
|
-
Every door goes through the handler **façade**, which is
|
|
30
|
+
Every door goes through the handler **façade**, which is where validation sits: unknown-key refusal,
|
|
31
31
|
collectors. A resolver or route you wire yourself against the storage — or worse, against the database —
|
|
32
|
-
is a second door with no
|
|
32
|
+
is a second door with no validator behind it, and the rules declared in the entities stop applying there.
|
|
33
33
|
|
|
34
34
|
Before adding a surface, reach for its **projection**:
|
|
35
35
|
|
|
@@ -39,7 +39,7 @@ Before adding a surface, reach for its **projection**:
|
|
|
39
39
|
| GraphQL | `registerAll(builder, app)` then `registerGraphQL(router, builder.toSchema())` — `@fougere/adapter-graphql` |
|
|
40
40
|
|
|
41
41
|
Hand-writing the types (`buildSchema`, raw SDL, one Pothos resolver per field) rebuilds what the
|
|
42
|
-
projection already derives, and drops the
|
|
42
|
+
projection already derives, and drops the validator on the way. `registerType` / `registerOperations`
|
|
43
43
|
exist to add what a projection cannot derive — never to replace it.
|
|
44
44
|
|
|
45
45
|
## Reading data
|
|
@@ -21,15 +21,15 @@ Two consequences worth stating, because they are what makes it hold:
|
|
|
21
21
|
a parallel entity that repeats the same fields. A field added to the entity is then accepted
|
|
22
22
|
without touching the view.
|
|
23
23
|
- **A handler writes what the input carries** (`{ ...attributes }`), it does not enumerate its
|
|
24
|
-
fields — otherwise a new field is
|
|
24
|
+
fields — otherwise a new field is validated, then silently not written.
|
|
25
25
|
|
|
26
26
|
If you are about to write the same constraint in two places, you have missed the derivation.
|
|
27
27
|
|
|
28
28
|
## A surface is a door, never a logic
|
|
29
29
|
|
|
30
|
-
Every door goes through the handler **façade**, which is
|
|
30
|
+
Every door goes through the handler **façade**, which is where validation sits: unknown-key refusal,
|
|
31
31
|
collectors. A resolver or route you wire yourself against the storage — or worse, against the database —
|
|
32
|
-
is a second door with no
|
|
32
|
+
is a second door with no validator behind it, and the rules declared in the entities stop applying there.
|
|
33
33
|
|
|
34
34
|
Before adding a surface, reach for its **projection**:
|
|
35
35
|
|
|
@@ -39,7 +39,7 @@ Before adding a surface, reach for its **projection**:
|
|
|
39
39
|
| GraphQL | `registerAll(builder, app)` then `registerGraphQL(router, builder.toSchema())` — `@fougere/adapter-graphql` |
|
|
40
40
|
|
|
41
41
|
Hand-writing the types (`buildSchema`, raw SDL, one Pothos resolver per field) rebuilds what the
|
|
42
|
-
projection already derives, and drops the
|
|
42
|
+
projection already derives, and drops the validator on the way. `registerType` / `registerOperations`
|
|
43
43
|
exist to add what a projection cannot derive — never to replace it.
|
|
44
44
|
|
|
45
45
|
## Reading data
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Crud } from '@fougere/core';
|
|
2
2
|
import Item from '../entities/Item.js';
|
|
3
3
|
|
|
4
|
-
/** The five ops, derived. Redefine one to
|
|
4
|
+
/** The five ops, derived. Redefine one to validate it, add a method to name an operation. */
|
|
5
5
|
export default class ItemHandler extends Crud(Item) {}
|
|
@@ -21,15 +21,15 @@ Two consequences worth stating, because they are what makes it hold:
|
|
|
21
21
|
a parallel entity that repeats the same fields. A field added to the entity is then accepted
|
|
22
22
|
without touching the view.
|
|
23
23
|
- **A handler writes what the input carries** (`{ ...attributes }`), it does not enumerate its
|
|
24
|
-
fields — otherwise a new field is
|
|
24
|
+
fields — otherwise a new field is validated, then silently not written.
|
|
25
25
|
|
|
26
26
|
If you are about to write the same constraint in two places, you have missed the derivation.
|
|
27
27
|
|
|
28
28
|
## A surface is a door, never a logic
|
|
29
29
|
|
|
30
|
-
Every door goes through the handler **façade**, which is
|
|
30
|
+
Every door goes through the handler **façade**, which is where validation sits: unknown-key refusal,
|
|
31
31
|
collectors. A resolver or route you wire yourself against the storage — or worse, against the database —
|
|
32
|
-
is a second door with no
|
|
32
|
+
is a second door with no validator behind it, and the rules declared in the entities stop applying there.
|
|
33
33
|
|
|
34
34
|
Before adding a surface, reach for its **projection**:
|
|
35
35
|
|
|
@@ -39,7 +39,7 @@ Before adding a surface, reach for its **projection**:
|
|
|
39
39
|
| GraphQL | `registerAll(builder, app)` then `registerGraphQL(router, builder.toSchema())` — `@fougere/adapter-graphql` |
|
|
40
40
|
|
|
41
41
|
Hand-writing the types (`buildSchema`, raw SDL, one Pothos resolver per field) rebuilds what the
|
|
42
|
-
projection already derives, and drops the
|
|
42
|
+
projection already derives, and drops the validator on the way. `registerType` / `registerOperations`
|
|
43
43
|
exist to add what a projection cannot derive — never to replace it.
|
|
44
44
|
|
|
45
45
|
## Reading data
|