@fougere/cli 0.1.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/LICENSE +21 -0
- package/README.md +17 -0
- package/app/commands/CallCommand.ts +68 -0
- package/app/commands/CheckCommand.ts +61 -0
- package/app/commands/CompletionCommand.ts +23 -0
- package/app/commands/GraphCommand.ts +61 -0
- package/app/commands/NewCommand.ts +122 -0
- package/app/commands/ServeCommand.ts +34 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +28 -0
- package/dist/bin.js.map +1 -0
- package/dist/bridge.d.ts +11 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +54 -0
- package/dist/bridge.js.map +1 -0
- package/dist/completion.d.ts +9 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +93 -0
- package/dist/completion.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/runner.d.ts +12 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +113 -0
- package/dist/runner.js.map +1 -0
- package/fronds/analysis/entities/Check.ts +5 -0
- package/fronds/analysis/entities/Graph.ts +6 -0
- package/fronds/analysis/handlers/CheckHandler.ts +90 -0
- package/fronds/analysis/handlers/GraphHandler.ts +32 -0
- package/fronds/analysis/services/ProjectScan.ts +35 -0
- package/fronds/scaffold/entities/BuildFrond.ts +5 -0
- package/fronds/scaffold/entities/Call.ts +6 -0
- package/fronds/scaffold/entities/Completion.ts +5 -0
- package/fronds/scaffold/entities/New.ts +19 -0
- package/fronds/scaffold/entities/Serve.ts +7 -0
- package/fronds/scaffold/entities/Sync.ts +6 -0
- package/fronds/scaffold/handlers/BuildFrondHandler.ts +97 -0
- package/fronds/scaffold/handlers/CallHandler.ts +9 -0
- package/fronds/scaffold/handlers/CompletionHandler.ts +8 -0
- package/fronds/scaffold/handlers/NewHandler.ts +8 -0
- package/fronds/scaffold/handlers/ServeHandler.ts +9 -0
- package/fronds/scaffold/handlers/SyncHandler.ts +329 -0
- package/fronds/scaffold/services/ProjectWriter.ts +176 -0
- package/package.json +52 -0
- package/templates/admin/fronds/admin/entities/User.ts +10 -0
- package/templates/admin/fronds/admin/handlers/UserHandler.ts +29 -0
- package/templates/admin/fronds/admin/package.json +10 -0
- package/templates/api/fronds/api/entities/Task.ts +9 -0
- package/templates/api/fronds/api/handlers/TaskHandler.ts +29 -0
- package/templates/api/fronds/api/package.json +10 -0
- package/templates/apps/nuxt/app/app.vue +25 -0
- package/templates/apps/nuxt/app/pages/index.vue +33 -0
- package/templates/apps/nuxt/nuxt.config.ts +6 -0
- package/templates/apps/nuxt/package.json +19 -0
- package/templates/apps/nuxt/tsconfig.json +3 -0
- package/templates/blog/app/pages/index.vue +12 -0
- package/templates/blog/app/pages/posts/index.vue +21 -0
- package/templates/blog/app/pages/posts/manage.vue +47 -0
- package/templates/blog/app/pages/posts/new.vue +34 -0
- package/templates/blog/fronds/blog/entities/Post.ts +12 -0
- package/templates/blog/fronds/blog/handlers/PostHandler.ts +35 -0
- package/templates/blog/fronds/blog/package.json +10 -0
- package/templates/flat/CLAUDE.md +65 -0
- package/templates/flat/_gitignore +6 -0
- package/templates/flat/app/app.vue +25 -0
- package/templates/flat/app/pages/index.vue +7 -0
- package/templates/flat/fougere.config.ts +7 -0
- package/templates/flat/nuxt.config.ts +4 -0
- package/templates/flat/package.json +24 -0
- package/templates/flat/pnpm-workspace.yaml +6 -0
- package/templates/flat/tsconfig.frond.json +25 -0
- package/templates/flat/tsconfig.json +3 -0
- package/templates/frond/CLAUDE.md +65 -0
- package/templates/frond/_gitignore +4 -0
- package/templates/frond/fougere.config.ts +5 -0
- package/templates/frond/fronds/__name__/entities/Post.ts +10 -0
- package/templates/frond/fronds/__name__/handlers/PostHandler.ts +26 -0
- package/templates/frond/fronds/__name__/package.json +10 -0
- package/templates/frond/package.json +18 -0
- package/templates/frond/pnpm-workspace.yaml +2 -0
- package/templates/frond/serve.mjs +25 -0
- package/templates/fronds/blank/entities/Item.ts +8 -0
- package/templates/fronds/blank/handlers/ItemHandler.ts +5 -0
- package/templates/fronds/blank/package.json +14 -0
- package/templates/fronds/blog/entities/Post.ts +10 -0
- package/templates/fronds/blog/handlers/PostHandler.ts +38 -0
- package/templates/fronds/blog/package.json +14 -0
- package/templates/workspace/CLAUDE.md +65 -0
- package/templates/workspace/_gitignore +6 -0
- package/templates/workspace/fougere.config.ts +5 -0
- package/templates/workspace/fronds/tsconfig.json +18 -0
- package/templates/workspace/package.json +19 -0
- package/templates/workspace/pnpm-workspace.yaml +10 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<NuxtPage />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<style>
|
|
6
|
+
:root { --fg: #1a1a1a; --muted: #6b7280; --border: #e5e7eb; --accent: #2563eb; }
|
|
7
|
+
* { box-sizing: border-box; }
|
|
8
|
+
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; color: var(--fg); line-height: 1.6; }
|
|
9
|
+
.wrap { max-width: 720px; margin: 3rem auto; padding: 0 1.5rem; }
|
|
10
|
+
a { color: var(--accent); text-decoration: none; }
|
|
11
|
+
a:hover { text-decoration: underline; }
|
|
12
|
+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
13
|
+
code { background: #f3f4f6; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.9em; }
|
|
14
|
+
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
15
|
+
th, td { text-align: left; padding: 0.6rem 0.5rem; border-bottom: 1px solid var(--border); }
|
|
16
|
+
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); }
|
|
17
|
+
label { display: block; font-size: 0.85rem; margin: 0.9rem 0 0.3rem; color: var(--muted); }
|
|
18
|
+
input, textarea { width: 100%; padding: 0.5rem 0.6rem; border: 1px solid var(--border); border-radius: 6px; font: inherit; }
|
|
19
|
+
button { padding: 0.5rem 0.9rem; border: 0; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; cursor: pointer; }
|
|
20
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
21
|
+
.badge { font-size: 0.72rem; padding: 0.15rem 0.55rem; border-radius: 999px; background: #f3f4f6; color: var(--muted); }
|
|
22
|
+
.badge.published { background: #dcfce7; color: #166534; }
|
|
23
|
+
.muted { color: var(--muted); }
|
|
24
|
+
.err { color: #b91c1c; font-size: 0.9rem; }
|
|
25
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The four primitives are auto-imported by @fougere/nuxt — no import line to write.
|
|
3
|
+
// They take an ENTITY, so this page names none: what is composed here is yours, and a
|
|
4
|
+
// scaffold that guessed at an entity shipped a page that could not run.
|
|
5
|
+
//
|
|
6
|
+
// import Post from '@frond/<your-frond>/entities/Post'
|
|
7
|
+
// const { items, loading } = await useQuery(Post, 'list')
|
|
8
|
+
// const { values, errors, submit } = useFormFor(Post)
|
|
9
|
+
// const { execute } = useCommand(Post, 'publish')
|
|
10
|
+
const { user } = useCurrentUser();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<main class="wrap">
|
|
15
|
+
<h1>🌿 Fougere</h1>
|
|
16
|
+
<p class="muted">
|
|
17
|
+
The app boots, the fronds are scanned, the surface is mounted. Write a page against
|
|
18
|
+
one of your entities — the commented block in this file is the whole API.
|
|
19
|
+
</p>
|
|
20
|
+
<p class="muted">Reader: <strong>{{ user?.name ?? 'nobody' }}</strong></p>
|
|
21
|
+
<p class="muted">What to write, and why: <code>CLAUDE.md</code> at the workspace root.</p>
|
|
22
|
+
</main>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<style>
|
|
26
|
+
:root { --fg: #1a1a1a; --muted: #6b7280; }
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; color: var(--fg); line-height: 1.6; }
|
|
29
|
+
.wrap { max-width: 720px; margin: 3rem auto; padding: 0 1.5rem; }
|
|
30
|
+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
31
|
+
code { background: #f3f4f6; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.9em; }
|
|
32
|
+
.muted { color: var(--muted); }
|
|
33
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fougere-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "nuxt dev",
|
|
7
|
+
"build": "nuxt build"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@fougere/core": "latest",
|
|
11
|
+
"@fougere/nuxt": "latest",
|
|
12
|
+
"@fougere/schema": "latest",
|
|
13
|
+
"@fougere/runtime": "latest",
|
|
14
|
+
"@fougere/container-fougere": "latest",
|
|
15
|
+
"better-sqlite3": "^12.8.0",
|
|
16
|
+
"kysely": "^0.28.17",
|
|
17
|
+
"nuxt": "^4.5.1"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<main class="wrap">
|
|
3
|
+
<h1>🌿 blog</h1>
|
|
4
|
+
<p>A Fougère frond: one entity (<code>Post</code>), its contracts, and a business operation. Everything below is derived from it.</p>
|
|
5
|
+
<p>
|
|
6
|
+
<NuxtLink to="/posts">Published</NuxtLink> ·
|
|
7
|
+
<NuxtLink to="/posts/manage">Drafts & publishing</NuxtLink> ·
|
|
8
|
+
<NuxtLink to="/posts/new">New draft</NuxtLink>
|
|
9
|
+
</p>
|
|
10
|
+
<p class="muted">The loop: create a draft → it stays private → publish it (a judged operation, not a field write) → it appears in the published list, live.</p>
|
|
11
|
+
</main>
|
|
12
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Post from '@frond/blog/entities/Post';
|
|
3
|
+
|
|
4
|
+
interface Card { id: string; title: string; status: string }
|
|
5
|
+
const { items: posts, loading, error } = await useQuery<Card>(Post, 'published');
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<main class="wrap">
|
|
10
|
+
<p><NuxtLink to="/">← home</NuxtLink></p>
|
|
11
|
+
<h1>Published posts</h1>
|
|
12
|
+
<p v-if="loading" class="muted">Loading…</p>
|
|
13
|
+
<p v-else-if="error" class="err">{{ error.message }}</p>
|
|
14
|
+
<p v-else-if="!posts.length" class="muted">
|
|
15
|
+
Nothing published yet. <NuxtLink to="/posts/manage">Publish a draft →</NuxtLink>
|
|
16
|
+
</p>
|
|
17
|
+
<ul v-else>
|
|
18
|
+
<li v-for="p in posts" :key="p.id">{{ p.title }}</li>
|
|
19
|
+
</ul>
|
|
20
|
+
</main>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Post from '@frond/blog/entities/Post';
|
|
3
|
+
|
|
4
|
+
interface Row { id: string; title: string; status: 'draft' | 'published' }
|
|
5
|
+
const { items: posts, loading } = await useQuery<Row>(Post, 'list');
|
|
6
|
+
const publish = useCommand(Post, 'publish');
|
|
7
|
+
const failed = ref('');
|
|
8
|
+
|
|
9
|
+
async function doPublish(id: string) {
|
|
10
|
+
failed.value = '';
|
|
11
|
+
try {
|
|
12
|
+
await publish.execute({ params: { id } });
|
|
13
|
+
} catch (e: any) {
|
|
14
|
+
failed.value = e?.message ?? 'Publish failed';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<main class="wrap">
|
|
21
|
+
<p><NuxtLink to="/">← home</NuxtLink></p>
|
|
22
|
+
<h1>Drafts & publishing</h1>
|
|
23
|
+
<p><NuxtLink to="/posts/new">+ New draft</NuxtLink></p>
|
|
24
|
+
<p v-if="failed" class="err">{{ failed }}</p>
|
|
25
|
+
<p v-if="loading" class="muted">Loading…</p>
|
|
26
|
+
<p v-else-if="!posts.length" class="muted">
|
|
27
|
+
No posts yet — <NuxtLink to="/posts/new">create one</NuxtLink>, it starts as a draft.
|
|
28
|
+
</p>
|
|
29
|
+
<table v-else>
|
|
30
|
+
<thead><tr><th>Title</th><th>Status</th><th></th></tr></thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
<tr v-for="p in posts" :key="p.id">
|
|
33
|
+
<td>{{ p.title }}</td>
|
|
34
|
+
<td><span class="badge" :class="{ published: p.status === 'published' }">{{ p.status }}</span></td>
|
|
35
|
+
<td style="text-align: right">
|
|
36
|
+
<button v-if="p.status === 'draft'" :disabled="publish.loading.value" @click="doPublish(p.id)">
|
|
37
|
+
Publish
|
|
38
|
+
</button>
|
|
39
|
+
</td>
|
|
40
|
+
</tr>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
<p class="muted" style="margin-top: 1rem">
|
|
44
|
+
Publishing revalidates the list automatically — the badge flips without a reload.
|
|
45
|
+
</p>
|
|
46
|
+
</main>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Post from '@frond/blog/entities/Post';
|
|
3
|
+
|
|
4
|
+
const { values, errors, submit, loading, error } = useFormFor(Post);
|
|
5
|
+
|
|
6
|
+
async function onSubmit() {
|
|
7
|
+
if (await submit()) navigateTo('/posts/manage');
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<main class="wrap">
|
|
13
|
+
<p><NuxtLink to="/posts/manage">← drafts</NuxtLink></p>
|
|
14
|
+
<h1>New draft</h1>
|
|
15
|
+
<p class="muted">
|
|
16
|
+
Created as a <span class="badge">draft</span>. You own the title and body —
|
|
17
|
+
<code>status</code> is the server's.
|
|
18
|
+
</p>
|
|
19
|
+
<form @submit.prevent="onSubmit">
|
|
20
|
+
<label>Title</label>
|
|
21
|
+
<input v-model="values.title" placeholder="Post title" autofocus />
|
|
22
|
+
<p v-if="errors.title" class="err">{{ errors.title }}</p>
|
|
23
|
+
|
|
24
|
+
<label>Body</label>
|
|
25
|
+
<textarea v-model="values.body" rows="8" placeholder="Write your post…"></textarea>
|
|
26
|
+
<p v-if="errors.body" class="err">{{ errors.body }}</p>
|
|
27
|
+
|
|
28
|
+
<p v-if="error" class="err">{{ error.message }}</p>
|
|
29
|
+
<p style="margin-top: 1rem">
|
|
30
|
+
<button type="submit" :disabled="loading">Create draft</button>
|
|
31
|
+
</p>
|
|
32
|
+
</form>
|
|
33
|
+
</main>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { entity, primary, text, auto, oneOf, readOnly } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
export default class Post extends entity({
|
|
4
|
+
id: primary(),
|
|
5
|
+
title: text({ min: 1, max: 200 }),
|
|
6
|
+
body: text(),
|
|
7
|
+
createdAt: auto(),
|
|
8
|
+
// Server-owned: a post is born a draft and flipped by the publish
|
|
9
|
+
// operation, never by a client writing the field. readOnly closes
|
|
10
|
+
// the inbound door — the field is projected out, never accepted in.
|
|
11
|
+
status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
|
|
12
|
+
}) {}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Crud, FougereError, ErrorCode } from '@fougere/core';
|
|
2
|
+
import Post from '../entities/Post.js';
|
|
3
|
+
|
|
4
|
+
// An Output contract — a read projection of the entity, declared once.
|
|
5
|
+
// The Input contract needs no class here: it is the entity's own input
|
|
6
|
+
// projection ('status' is readOnly), which useFormFor(Post) renders.
|
|
7
|
+
export class PostCard extends Post.pick('id', 'title', 'status') {}
|
|
8
|
+
|
|
9
|
+
// Crud(Post) gives list/create/update/delete for free — the accelerator.
|
|
10
|
+
// 'publish' is the real business contract: a state transition that judges
|
|
11
|
+
// before it realises — an operation, not a field write. The golden path.
|
|
12
|
+
export default class PostHandler extends Crud(Post) {
|
|
13
|
+
/**
|
|
14
|
+
* The draft→published transition. Judge: exists, draft only.
|
|
15
|
+
* Realise: the server flips the owned field.
|
|
16
|
+
*/
|
|
17
|
+
async publish(id: string): Promise<Post> {
|
|
18
|
+
const post = await this.orm.findById(id);
|
|
19
|
+
if (!post) {
|
|
20
|
+
throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `Post '${id}' not found`, entity: 'post', operation: 'publish' });
|
|
21
|
+
}
|
|
22
|
+
if ((post as { status?: string }).status === 'published') {
|
|
23
|
+
throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published', entity: 'post', operation: 'publish' });
|
|
24
|
+
}
|
|
25
|
+
return this.orm.update(id, { status: 'published' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Only published posts exist for the outside world, projected to the card. */
|
|
29
|
+
async published(): Promise<PostCard[]> {
|
|
30
|
+
const all = await this.orm.list();
|
|
31
|
+
return all
|
|
32
|
+
.filter((p) => (p as { status?: string }).status === 'published')
|
|
33
|
+
.map((p) => ({ id: String(p.id), title: String(p.title), status: 'published' })) as PostCard[];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Working in this application
|
|
2
|
+
|
|
3
|
+
Built with **Fougere**. This file is read by coding agents before they touch anything — it states
|
|
4
|
+
the few facts that change what they should write.
|
|
5
|
+
|
|
6
|
+
## One declaration, everything derives
|
|
7
|
+
|
|
8
|
+
A field belongs to its entity and to nowhere else. Adding it there alone produces the SQL column,
|
|
9
|
+
the validation, the input contract, the write and the output shape. Additive storage changes need no
|
|
10
|
+
hand-written migration; renames, removals and type changes still do. No DTO or permitted-params list
|
|
11
|
+
needs to stay in step.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// entities/Product.ts — and nothing else
|
|
15
|
+
sku: text({ min: 3, max: 32 }),
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Two consequences worth stating, because they are what makes it hold:
|
|
19
|
+
|
|
20
|
+
- **An input view derives from the entity** — `Order.omit('id', 'status').extend({ items })`, never
|
|
21
|
+
a parallel entity that repeats the same fields. A field added to the entity is then accepted
|
|
22
|
+
without touching the view.
|
|
23
|
+
- **A handler writes what the input carries** (`{ ...attributes }`), it does not enumerate its
|
|
24
|
+
fields — otherwise a new field is judged, then silently not written.
|
|
25
|
+
|
|
26
|
+
If you are about to write the same constraint in two places, you have missed the derivation.
|
|
27
|
+
|
|
28
|
+
## A surface is a door, never a logic
|
|
29
|
+
|
|
30
|
+
Every door goes through the handler **façade**, which is the judge: validation, unknown-key refusal,
|
|
31
|
+
collectors. A resolver or route you wire yourself against the ORM — or worse, against the database —
|
|
32
|
+
is a second door with no judge behind it, and the rules declared in the entities stop applying there.
|
|
33
|
+
|
|
34
|
+
Before adding a surface, reach for its **projection**:
|
|
35
|
+
|
|
36
|
+
| surface | the call |
|
|
37
|
+
|---|---|
|
|
38
|
+
| REST | `generateRoutes(app)` then `registerRoutes(router, routes)` — `@fougere/schema-rest` |
|
|
39
|
+
| GraphQL | `registerAll(builder, app)` then `registerGraphQL(router, builder.toSchema())` — `@fougere/schema-graphql` |
|
|
40
|
+
|
|
41
|
+
Hand-writing the types (`buildSchema`, raw SDL, one Pothos resolver per field) rebuilds what the
|
|
42
|
+
projection already derives, and drops the judge on the way. `registerType` / `registerOperations`
|
|
43
|
+
exist to add what a projection cannot derive — never to replace it.
|
|
44
|
+
|
|
45
|
+
## Reading data
|
|
46
|
+
|
|
47
|
+
`EntityOrm`, injected by type, is the only data access:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
list(options?) every row — `options.where` filters, plus paging and sorting
|
|
51
|
+
findById(id) one row by id
|
|
52
|
+
findBy(criteria) one row by criteria — findBy({ email })
|
|
53
|
+
findAllBy(criteria) every row of a criteria — findAllBy({ order_id }) IS a one-to-many relation
|
|
54
|
+
create / update / delete
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Read a relation with `findAllBy`. Never read a whole table to filter it in memory.
|
|
58
|
+
|
|
59
|
+
## Checking your work
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx tsc -p tsconfig.frond.json # the compiler — free, immediate, and it catches most of it
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run it. It is the first judge, and the cheapest.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<NuxtPage />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<style>
|
|
6
|
+
:root { --fg: #1a1a1a; --muted: #6b7280; --border: #e5e7eb; --accent: #2563eb; }
|
|
7
|
+
* { box-sizing: border-box; }
|
|
8
|
+
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; color: var(--fg); line-height: 1.6; }
|
|
9
|
+
.wrap { max-width: 720px; margin: 3rem auto; padding: 0 1.5rem; }
|
|
10
|
+
a { color: var(--accent); text-decoration: none; }
|
|
11
|
+
a:hover { text-decoration: underline; }
|
|
12
|
+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
13
|
+
code { background: #f3f4f6; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.9em; }
|
|
14
|
+
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
15
|
+
th, td { text-align: left; padding: 0.6rem 0.5rem; border-bottom: 1px solid var(--border); }
|
|
16
|
+
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); }
|
|
17
|
+
label { display: block; font-size: 0.85rem; margin: 0.9rem 0 0.3rem; color: var(--muted); }
|
|
18
|
+
input, textarea { width: 100%; padding: 0.5rem 0.6rem; border: 1px solid var(--border); border-radius: 6px; font: inherit; }
|
|
19
|
+
button { padding: 0.5rem 0.9rem; border: 0; border-radius: 6px; background: var(--accent); color: #fff; font: inherit; cursor: pointer; }
|
|
20
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
21
|
+
.badge { font-size: 0.72rem; padding: 0.15rem 0.55rem; border-radius: 999px; background: #f3f4f6; color: var(--muted); }
|
|
22
|
+
.badge.published { background: #dcfce7; color: #166534; }
|
|
23
|
+
.muted { color: var(--muted); }
|
|
24
|
+
.err { color: #b91c1c; font-size: 0.9rem; }
|
|
25
|
+
</style>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FougereConfig } from '@fougere/core';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
// File-backed on purpose: your data must survive dev reloads and deploys.
|
|
5
|
+
// Lives under .data/ (gitignored) — the single writable-state dir.
|
|
6
|
+
db: { dialect: 'sqlite', path: '.data/app.db' },
|
|
7
|
+
} satisfies FougereConfig;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fougere-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"packageManager": "pnpm@11.20.0",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "nuxt dev",
|
|
8
|
+
"build": "nuxt build",
|
|
9
|
+
"typecheck": "tsc -p tsconfig.frond.json"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@fougere/core": "latest",
|
|
13
|
+
"@fougere/nuxt": "latest",
|
|
14
|
+
"@fougere/schema": "latest",
|
|
15
|
+
"@fougere/runtime": "latest",
|
|
16
|
+
"@fougere/container-fougere": "latest",
|
|
17
|
+
"better-sqlite3": "^12.8.0",
|
|
18
|
+
"kysely": "^0.28.17",
|
|
19
|
+
"nuxt": "^4.5.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.19.15"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The domain typechecks on its own, without Nuxt and without `.nuxt/tsconfig.json`.
|
|
3
|
+
// `include` names the convention directories rather than `["."]`, which would swallow
|
|
4
|
+
// `app/` and `nuxt.config.ts` — the whole point being that the domain does not need them.
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"module": "Node16",
|
|
8
|
+
"moduleResolution": "Node16",
|
|
9
|
+
"lib": ["ESNext"],
|
|
10
|
+
"types": ["node"],
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"noEmit": true
|
|
15
|
+
},
|
|
16
|
+
"include": [
|
|
17
|
+
"entities",
|
|
18
|
+
"handlers",
|
|
19
|
+
"presenters",
|
|
20
|
+
"collectors",
|
|
21
|
+
"seeds",
|
|
22
|
+
"services",
|
|
23
|
+
"repositories"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Working in this application
|
|
2
|
+
|
|
3
|
+
Built with **Fougere**. This file is read by coding agents before they touch anything — it states
|
|
4
|
+
the few facts that change what they should write.
|
|
5
|
+
|
|
6
|
+
## One declaration, everything derives
|
|
7
|
+
|
|
8
|
+
A field belongs to its entity and to nowhere else. Adding it there alone produces the SQL column,
|
|
9
|
+
the validation, the input contract, the write and the output shape. Additive storage changes need no
|
|
10
|
+
hand-written migration; renames, removals and type changes still do. No DTO or permitted-params list
|
|
11
|
+
needs to stay in step.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// fronds/<frond>/entities/Product.ts — and nothing else
|
|
15
|
+
sku: text({ min: 3, max: 32 }),
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Two consequences worth stating, because they are what makes it hold:
|
|
19
|
+
|
|
20
|
+
- **An input view derives from the entity** — `Order.omit('id', 'status').extend({ items })`, never
|
|
21
|
+
a parallel entity that repeats the same fields. A field added to the entity is then accepted
|
|
22
|
+
without touching the view.
|
|
23
|
+
- **A handler writes what the input carries** (`{ ...attributes }`), it does not enumerate its
|
|
24
|
+
fields — otherwise a new field is judged, then silently not written.
|
|
25
|
+
|
|
26
|
+
If you are about to write the same constraint in two places, you have missed the derivation.
|
|
27
|
+
|
|
28
|
+
## A surface is a door, never a logic
|
|
29
|
+
|
|
30
|
+
Every door goes through the handler **façade**, which is the judge: validation, unknown-key refusal,
|
|
31
|
+
collectors. A resolver or route you wire yourself against the ORM — or worse, against the database —
|
|
32
|
+
is a second door with no judge behind it, and the rules declared in the entities stop applying there.
|
|
33
|
+
|
|
34
|
+
Before adding a surface, reach for its **projection**:
|
|
35
|
+
|
|
36
|
+
| surface | the call |
|
|
37
|
+
|---|---|
|
|
38
|
+
| REST | `generateRoutes(app)` then `registerRoutes(router, routes)` — `@fougere/schema-rest` |
|
|
39
|
+
| GraphQL | `registerAll(builder, app)` then `registerGraphQL(router, builder.toSchema())` — `@fougere/schema-graphql` |
|
|
40
|
+
|
|
41
|
+
Hand-writing the types (`buildSchema`, raw SDL, one Pothos resolver per field) rebuilds what the
|
|
42
|
+
projection already derives, and drops the judge on the way. `registerType` / `registerOperations`
|
|
43
|
+
exist to add what a projection cannot derive — never to replace it.
|
|
44
|
+
|
|
45
|
+
## Reading data
|
|
46
|
+
|
|
47
|
+
`EntityOrm`, injected by type, is the only data access:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
list(options?) every row — `options.where` filters, plus paging and sorting
|
|
51
|
+
findById(id) one row by id
|
|
52
|
+
findBy(criteria) one row by criteria — findBy({ email })
|
|
53
|
+
findAllBy(criteria) every row of a criteria — findAllBy({ order_id }) IS a one-to-many relation
|
|
54
|
+
create / update / delete
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Read a relation with `findAllBy`. Never read a whole table to filter it in memory.
|
|
58
|
+
|
|
59
|
+
## Checking your work
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx tsc -p fronds --noEmit # the compiler — free, immediate, and it catches most of it
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run it. It is the first judge, and the cheapest.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { entity, primary, text, auto, oneOf, readOnly } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
export default class Post extends entity({
|
|
4
|
+
id: primary(),
|
|
5
|
+
title: text({ min: 1, max: 200 }),
|
|
6
|
+
body: text(),
|
|
7
|
+
createdAt: auto(),
|
|
8
|
+
// Server-owned: born a draft, flipped by the publish operation.
|
|
9
|
+
status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
|
|
10
|
+
}) {}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Crud, FougereError, ErrorCode } from '@fougere/core';
|
|
2
|
+
import Post from '../entities/Post.js';
|
|
3
|
+
|
|
4
|
+
export class PostCard extends Post.pick('id', 'title', 'status') {}
|
|
5
|
+
|
|
6
|
+
export default class PostHandler extends Crud(Post) {
|
|
7
|
+
/** The draft→published transition — an operation, not a field write. */
|
|
8
|
+
async publish(id: string): Promise<Post> {
|
|
9
|
+
const post = await this.orm.findById(id);
|
|
10
|
+
if (!post) {
|
|
11
|
+
throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `Post '${id}' not found`, entity: 'post', operation: 'publish' });
|
|
12
|
+
}
|
|
13
|
+
if ((post as { status?: string }).status === 'published') {
|
|
14
|
+
throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published', entity: 'post', operation: 'publish' });
|
|
15
|
+
}
|
|
16
|
+
return this.orm.update(id, { status: 'published' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only published posts, projected to the card. */
|
|
20
|
+
async published(): Promise<PostCard[]> {
|
|
21
|
+
const all = await this.orm.list();
|
|
22
|
+
return all
|
|
23
|
+
.filter((p) => (p as { status?: string }).status === 'published')
|
|
24
|
+
.map((p) => ({ id: String(p.id), title: String(p.title), status: 'published' })) as PostCard[];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fougere-frond",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"serve": "node serve.mjs"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@fougere/core": "latest",
|
|
10
|
+
"@fougere/schema": "latest",
|
|
11
|
+
"@fougere/runtime": "latest",
|
|
12
|
+
"@fougere/container-fougere": "latest",
|
|
13
|
+
"@fougere/transport-http": "latest",
|
|
14
|
+
"better-sqlite3": "^12.8.0",
|
|
15
|
+
"kysely": "^0.28.17",
|
|
16
|
+
"jiti": "^2.4.2"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This frond, alone in its own process — the gradient's third freedom.
|
|
3
|
+
*
|
|
4
|
+
* An app in another repo consumes it with one line:
|
|
5
|
+
* remotes: { <frond>: 'http://127.0.0.1:4000' }
|
|
6
|
+
* and pulls its contract with:
|
|
7
|
+
* fougere sync <frond> --from http://127.0.0.1:4000
|
|
8
|
+
* Nothing in the frond changes — only where it runs.
|
|
9
|
+
*/
|
|
10
|
+
import { createJiti } from 'jiti';
|
|
11
|
+
import { createLocalRunner, setModuleLoader, Logger } from '@fougere/core';
|
|
12
|
+
import { bootAppFromConfig } from '@fougere/runtime';
|
|
13
|
+
import { serve } from '@fougere/transport-http';
|
|
14
|
+
|
|
15
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
16
|
+
setModuleLoader((filePath) => jiti.import(filePath));
|
|
17
|
+
|
|
18
|
+
const log = new Logger('frond-host');
|
|
19
|
+
|
|
20
|
+
// `topology: false` — this process *is* the frond, it doesn't route back out.
|
|
21
|
+
// Storage comes from fougere.config.ts; this host names no engine.
|
|
22
|
+
const app = await bootAppFromConfig(process.cwd(), { topology: false });
|
|
23
|
+
|
|
24
|
+
const { port } = await serve(createLocalRunner(app), { port: Number(process.env.PORT ?? 4000) });
|
|
25
|
+
log.info(`frond served — POST http://127.0.0.1:${port}/_fougere/call`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { entity, primary, text, auto } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
/** Rename it, then declare your fields — this is the whole shape of an entity. */
|
|
4
|
+
export default class Item extends entity({
|
|
5
|
+
id: primary(),
|
|
6
|
+
name: text({ min: 1, max: 200 }),
|
|
7
|
+
createdAt: auto(),
|
|
8
|
+
}) {}
|