Bring your schema
The previous page is additive about the host: your routing, your pages, your rendering and your other server routes are untouched. It is not additive about the domain. The entity is the one thing Fougere must own, because it is the one thing everything else is derived from.
So adopting Fougere has exactly one cost, and this page is it: you declare your model in its vocabulary. What follows is how to keep that cost small, bounded, and paid one entity at a time.
One entity at a time
You do not convert a domain. You convert an entity.
migrate() is additive: it emits createTable, addColumn, addConstraint and
createIndex, and nothing else. It never drops a column, never rewrites a type, and never
proposes anything for a table no entity declares. A Fougere entity therefore creates its
own table beside the ones your current storage manages, in the same database, and your existing
rows are out of its reach.
The case to watch is a name collision: an entity whose table already exists gets addColumn
proposed on your table rather than a fresh one. Rename one of the two, or hand the table
over on purpose.
The mapping
| Prisma | Fougere |
|---|---|
String | text() — with { min, max } when you have a rule |
String? | optional(text()), or nullable(text()) if the column is truly nullable |
Int, Float | number() |
Boolean | bool() |
DateTime | date() |
Json | json() |
String @id @default(cuid()) | primary() |
@unique | unique(field) |
@@unique([listId, docId]) | unique: [['listId', 'docId']], in entity()'s second argument |
@index | indexed(field) |
a foreign key + @relation | ref(Author) |
| the reverse side of that relation | many(Post) — a role, no column |
@default(now()) | created() |
@updatedAt | updated() |
an enum + @default("draft") | oneOf('draft', 'published', { default: 'draft' }) |
@db.VarChar(160) | text({ max: 160 }) |
The last row is the whole thesis in one line. @db.VarChar(160) names a dialect;
text({ max: 160 }) names the rule, and the dialect is derived from it — along with the
CHECK, the JSON Schema, the GraphQL type and the form's maxlength.
What has no equivalent
A Prisma model says what the column is. That is one of four axes. The other three have no column to map from, and they are why the conversion is not mechanical:
lifecycle— who writes the value, and when.created()is not@default(now())with a nicer name: it also states that the value cannot be rewritten on update.readOnly(oneOf(…))states that the client never supplies it at all.boundary— who may see it.writeOnly()for the password that goes in and never comes out.readOnly()for the field the server stamps.role— what part it plays.primary,ref,many,unique— read by the DDL for keys and constraints, and by the card for relations.
You enforce all three today. They live in a service method, a DTO, a select: clause, an
@Exclude() decorator, a review comment. They are real rules with no single home.
The conversion is not mechanical because you are writing down, for the first time, rules you were already applying.
That is the work, and it is also the return: from that declaration the validator, the table, the GraphQL type, the REST route and the form contract are all projections — see Entities for the four axes in full.
What stays where it is
- Your storage. Fougere claims no ownership of it. In the routes you have not converted, keep querying exactly as you do now.
- Unconverted tables. Nothing at boot requires the whole database to be declared. The
scan finds what is in
entities/, and is silent about the rest. - Raw statements on a converted entity.
storage.clientis the escape hatch on the storage port — the underlying Kysely instance for the SQL adapter, scoped to that entity's own data. It is the one path the judge does not sit on; see Storage.
Next: The CLI — composing a workspace, hosting one Frond, calling an operation.