Entities and the Repo
Databases are where type systems traditionally give up. The schema lives in SQL, the code’s model of it lives in classes or structs, and an ORM performs diplomacy between them. When they disagree, you find out at query time. Mar closes this gap the same way it closed the API gap: one declaration, in the language, from which everything else is derived.
An entity is a typed table
-- Backend/Tasks.mar
type alias Task =
{ id : Int
, name : String
, done : Bool
, createdAt : Time
, userId : Int
}
tasks : Entity Task
tasks =
Entity.define
{ name = "tasks"
, columns =
{ id = Entity.serial
, name = Entity.text Entity.notNull
, done = Entity.bool Entity.notNull
, createdAt = Entity.timestamp Entity.notNull
, userId = Entity.int Entity.notNull
}
, uniques = []
}
The record alias is the row type your whole app uses; Entity.define maps it onto a table. The columns record must mirror the record’s fields, and the compiler checks that it does. An entity carries schema only: no methods, no business logic, no lifecycle hooks. Behavior lives in handlers, where it can be read.
The column vocabulary follows the type vocabulary: Entity.serial (auto-increment key), Entity.int, Entity.text, Entity.bool, Entity.timestamp (a Time, stored as Unix milliseconds), Entity.enum (a closed union, CHECK-constrained), and Entity.decimal scale (an exact Decimal with a fixed number of places; Entity.decimal 2 stores money as integer cents underneath, and a write with more places than the column aborts instead of rounding silently).
uniques declares unique indexes as lists of column groups ([["commentId", "userId", "emoji"]] reads “one reaction per user per comment”), turning a business rule into a database-enforced fact.
Migrations you do not write
Here is the feature people miss most when they leave: there are no migration files. On startup, the runtime compares your entity declarations to the actual SQLite schema:
- Additive changes (new table, new column, new index) apply automatically, logged with timing.
- Destructive changes (drop a column, change a type) refuse to start with a clear error and a suggested manual step. Mar will not silently eat data to make the schema match.
The workflow is: edit the record, edit the columns to match (the compiler holds your hand), restart mar dev. The migration is a consequence of the type, in the same way the JSON encoding was a consequence of the type. Ordinary schema evolution stops being an artifact you author and review; it becomes a diff the runtime derives.
Underneath sits SQLite, embedded in the server binary: zero-configuration, a single file, absurdly reliable, and comfortably sufficient for the team-sized apps Mar targets. (It is also a real limit for some products; the trade-offs chapter treats this honestly.)
The Repo: reading and writing rows
Data access is one small module, and every operation returns a Task (the backend effect type, next chapter):
Repo.all : Entity a -> Task (List a)
Repo.findById : Entity a -> Int -> Task (Maybe a)
Repo.findBy : Entity a -> fields -> Task (List a)
Repo.create : Entity a -> fields -> Task a
Repo.update : Entity a -> Int -> fields -> Task (Maybe a)
Repo.deleteById : Entity a -> Int -> Task ()
The types encode the semantics you usually learn from documentation, or incidents:
findByIdreturnsMaybe a. The row might not exist, so the type says so, and the caller must handleNothing. No “record not found” exception.createtakes the row minus the serial id (the runtime fills it). You cannot supply a bogus id, because the record type for the argument does not have the field.updatetakes just the columns to change and returnsMaybe a:Nothingtold you the id was stale, in the return type, not in a thrown surprise.findByfilters by example:Repo.findBy tasks { userId = user.id }returns the rows whose columns equal the record you pass.
listTasksImpl : () -> Shared.User -> Task (List Shared.Task)
listTasksImpl _ user =
Repo.findBy tasks { userId = user.id }
|> Task.map (List.sortWith byPosition)
Where are the joins?
Deliberately absent, for now. There is no query builder, no relations DSL, no raw SQL escape hatch in app code. You read rows with the six operations and compose the rest with ordinary Mar: List.filter, List.map, Task.andThen across entities.
This is a real constraint, and it is worth understanding the reasoning rather than just the rule. Query DSLs are where ORMs become haunted: the innocent-looking property access that fires N+1 queries, the lazy relation that explodes outside its session, the string of chained methods that generates SQL nobody predicted. Mar’s Repo is small enough that every data access is explicit, predictable, and visible in the handler that performs it. For the target class of app, most “joins” are a findBy plus an in-memory pass over a few hundred rows, and SQLite makes those reads very cheap.
When a real need outgrows this, the language will grow a typed answer (the same way Decimal became the answer for money columns). What it will not grow is a string-typed side door.
The register, one more time
An entity declaration is a single source from which Mar derives: the table, its migrations, the row type, the JSON encoding at the API boundary, and the admin panel’s browsing UI at /_mar/admin. Five artifacts that in a conventional stack are five files in four languages, drifting. Here they are projections of one declaration, and the compiler owns the projections.
Handlers, meanwhile, have been returning these Task values everywhere. Time to look at what a Task actually is.