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

PrismaFougere
Stringtext() — with { min, max } when you have a rule
String?optional(text()), or nullable(text()) if the column is truly nullable
Int, Floatnumber()
Booleanbool()
DateTimedate()
Jsonjson()
String @id @default(cuid())primary()
@uniqueunique(field)
@@unique([listId, docId])unique: [['listId', 'docId']], in entity()'s second argument
@indexindexed(field)
a foreign key + @relationref(Author)
the reverse side of that relationmany(Post) — a role, no column
@default(now())created()
@updatedAtupdated()
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.client is 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.

Built with Fougere — this site runs on the framework it documents.