How MatterData works
You describe an application in text files. MatterData compiles that description into a model, and one shared runtime serves every request against the model.
.mtd files + hook modules │ ▼ compiler ── resolves every reference, checks every type, plans the migration │ ▼ canonical model + SQLite schema │ ▼ shared runtime ── one Go binary, serving every application │ ▼ tenant.db + tenant.blobs/What you write, and what you never write
Section titled “What you write, and what you never write”You write tables, pages and access rules in .mtd files, and optional business logic in
TypeScript modules. Those are the only files you write.
Until September 2026 these files ended in .edg. The compiler refuses a file that still has the
old extension and names the git mv command that renames it. An organization that already runs
is not affected: the engine serves the model it stored at install and does not read your files
again.
You do not write routing, SQL, HTML templates, form handling, validation, session management, CSRF tokens, pagination, sorting, image processing or CSV output. The runtime owns all of it, which is why a fix to any of it reaches every application at once.
The compiler runs before anybody’s request
Section titled “The compiler runs before anybody’s request”Compilation is where a mistake becomes a build error instead of a failure in production.
The compiler resolves every reference. A column name that does not exist fails the build, and the message names the column you probably meant. The compiler also checks every expression’s type.
It reads your access rules and refuses a structure that it cannot enforce. One example is an application scope reached through an optional reference. A record behind such a reference lands in no scope at all, so nobody sees it and nothing reports it.
The compiler owns your database schema as well. It emits the SQLite table definitions, and it compares them against the installed schema to produce a migration plan. A guard stops a destructive change from running unannounced.
The model is canonical: the same source always produces the same bytes. It is also versioned and has a published JSON Schema, so other tools can read it.
One runtime interprets the model
Section titled “One runtime interprets the model”The runtime is a single Go binary with no external service behind it. It draws every page from the model, including:
- listings, records, forms, statistics and crosstabs
- editable listings, where one save writes many rows in one transaction
- navigation, page layout and the tenant’s theme
- lifecycle buttons that move a record between states
- image uploads and authorized image delivery
- Markdown rendering
- CSV files and calendar feeds
Every page works with JavaScript turned off. Script is an upgrade layer and never a requirement.
What happens in one request
Section titled “What happens in one request”The order matters.
- The runtime sets security headers and rejects an oversized body, before it opens any database.
- The request host picks the tenant.
club.matterdata.exampleopens one SQLite file and nothing else can be reached from that request. - The application is resolved once, from the first path segment, against the mounts that tenant holds. The three resolutions below are all “in this application”.
- Identity is resolved once, from the session cookie, against that tenant’s own file. A cookie planted by a sibling tenant does not resolve. The login is the organization’s and the role is the application’s.
- The application scope is resolved once, if your application declares one, so every query in the request binds the same term, class or season.
- The address picks the route, and the route carries the access rule you wrote. A role that may not open the route never reaches the handler.
- The time zone is resolved once, now that the page is known. A heading, a table cell, a form control and a CSV of one page therefore agree about which hour something happened in.
- On a mutating request, the runtime parses the body and checks the CSRF token.
- The handler answers the row question inside the query. Your row rules become conditions of the SQL statement. A record you may not see is never read and then hidden.
- The page renders.
Steps 3, 4, 5 and 7 are single resolutions on purpose. A security question answered twice is a question with two answers that can disagree.
One file per organization
Section titled “One file per organization”Each organization gets one SQLite database holding its model, its data, its users and its
sessions. Isolation is the file boundary rather than a tenant_id column that a forgotten WHERE
clause can leak past.
That file is ordinary SQLite. Nothing the compiler emits depends on a function or a collation that the runtime adds. You can therefore open a tenant database with any SQLite tool and read your own data. This is a deliberate constraint on the engine, because the tenant file is also the backup.
One file can hold several applications, each served under its own first path segment. Each
table is stored as <application>__<table>, so two applications can both declare events.
Your .mtd files keep the short name. sqlite3 opens the file and .tables lists the stored
names. Read An organization, its applications and one login.
Uploaded images are the exception to “one file”. They are re-encoded and stored by content digest in a directory beside the database. Image bytes in rows were measured first: an edit that did not touch the picture cost 78 times the write-ahead log and 97 times the wall clock. A backup is therefore both things, the database file and its blob directory.
An organization that resets itself every night has a third thing to copy: the template it returns to, which is a database file and a blob directory beside the live pair. A backup without it restores an organization that resets itself into a state you no longer have.
Hooks run inside the write
Section titled “Hooks run inside the write”A TypeScript module can run before or after a row is written, and when a form is saved. It runs in an embedded JavaScript engine, under a time budget, with a context that grants named capabilities.
That context is deliberately narrow. A hook asks for the rows of a named table and cannot send a SQL statement of its own. There is no filesystem access and no outbound HTTP.
A before hook runs inside the write’s transaction, and whatever it changes is validated again
against every rule the submission already passed. The runtime does not trust a hook’s output more
than it trusts a form post.
See Add business logic with a hook for what a hook can and cannot do today.