schema NAME claims a file’s models for one logical database. It is the name a
[datasources.*] entry in atoll.toml matches when it decides which engine
runs a query.
schema seaport
model Booking {
@id
id: int
reference: string
}
model Leg {
@id
id: int
booking_id: int
port: string
}
fn legs_for(booking: int): []Leg ! QueryError {
return FROM Leg WHERE booking_id == booking ORDER BY id?
}
fn main(): void {
match legs_for(1) {
Ok(legs) => println("${legs.len()} legs")
Err(e) => println("read failed")
}
}Both models belong to seaport. The declaration is compile-time metadata that
feeds model registration, DDL generation, migration selection, and datasource
routing.
One schema per file
The compiler takes the first schema declaration in a file and applies it
to every model in that file. Position does not matter, and a second
declaration in the same file is inert:
schema seaport
schema audit
// Both of these belong to `seaport`. `audit` claims nothing.
model Booking {
@id
id: int
}
model AuditEntry {
@id
id: int
action: string
}
fn trail(): []AuditEntry ! QueryError {
return FROM AuditEntry ORDER BY id DESC?
}
fn main(): void {
match trail() {
Ok(rows) => println("${rows.len()} audit rows")
Err(e) => println("read failed")
}
}To place models in different schemas, put them in different files. Keep one
schema declaration per file, at the top, where a reader will find it.
The older directory convention — a schema.at file claiming every model in the
directory tree below it — is not yet a complete project-level guarantee. Until
that lands, a file that declares models and needs an unambiguous association
should declare its own schema.
Models without a schema
A schema declaration is optional. Models in a file with none compile and query normally; they simply have no schema to match against, so they take the default route:
model Product {
@id
id: int
sku: string
price: float
}
fn cheap(ceiling: float): []Product ! QueryError {
return FROM Product WHERE price <= ceiling ORDER BY price?
}
fn main(): void {
match cheap(9.99) {
Ok(rows) => println("${rows.len()} cheap products")
Err(e) => println("read failed")
}
}With exactly one configured datasource, that datasource serves them. With zero or several, they fall back to SQLite rendering — fine for a test, a configuration mistake in a deployable application.
Not a value, not a namespace
schema introduces no binding. The name is not importable, not callable, and
not addressable from expression code:
schema seaport
model Booking {
@id
id: int
}
fn f(): int {
return seaport.count
}It is also a top-level declaration, so it cannot appear inside a function:
fn f(): int {
schema seaport
return 1
}And the name is one plain identifier — no dots, no path segments:
schema seaport.bookings
model Booking {
@id
id: int
}Schema versus module
Modules and schemas answer different questions, and a file usually has both:
| Concept | Controls | Declared by |
|---|---|---|
| Module | source names, imports, visibility | the directory (plus module NAME) |
| Schema | which logical database owns a model | schema NAME |
| Datasource | engine, connection, dialect, write policy | [datasources.*] in atoll.toml |
module catalog
schema shop
model Product {
@id
id: int
sku: string
price: float
}
fn by_sku(wanted: string): { id: int, sku: string, price: float }? ! QueryError {
return ONE FROM Product WHERE sku == wanted SELECT { id, sku, price }?
}
fn main(): void {
match by_sku("A-1") {
Ok(row) => println("price ${row?.price ?? 0.0}")
Err(e) => println("lookup failed")
}
}One module can query models from several schemas, and one schema can be routed to different engines between deployments. Neither fact changes import syntax.
Table naming still has to stay unambiguous within a generated schema even when
two modules happen to declare same-named models; use @sql.name to
disambiguate.
Joins stay inside a schema
A join reaches across models, so the models it touches should share a schema and therefore a route:
schema seaport
model Booking {
@id
id: int
schedule_id: int
passengers: int
BELONGS TO schedule: Schedule VIA schedule_id
}
model Schedule {
@id
id: int
vessel: string
capacity: int
}
type ManifestRow = { id: int, vessel: string, passengers: int }
fn manifest(minimum: int): []ManifestRow ! QueryError {
return FROM Booking
JOIN Schedule
WHERE passengers >= minimum
SELECT { id, vessel: Schedule.vessel, passengers }?
}
fn main(): void {
match manifest(2) {
Ok(rows) => {
mut heads := 0
for row in rows { heads = heads + row.passengers }
println("${rows.len()} sailings, ${heads} passengers")
}
Err(e) => println("manifest failed")
}
}Cross-schema join rejection is not fully enforced today, so a program that
compiles is not by itself proof of a clean data boundary. Transaction checking
is strict: every query inside one transaction block must resolve to the
same datasource route.
Where cross-schema access needs authorization, consistency, or deployment restrictions, put an explicit service or repository function in the way. A schema name is routing metadata, not an access-control mechanism.
Changing a model’s schema
Moving a model between schemas is a data migration, not a source refactor. One edit can change several outputs at once:
| Surface | Possible change |
|---|---|
| Query routing | a different datasource and engine may be selected |
| Dialect gates | previously accepted SQL may become unsupported |
| Transactions | operations may no longer share one route identity |
| DDL snapshots | tables, foreign keys, and indexes move between snapshots |
| Migrations | the move may render as a destructive drop plus create |
| Runtime data | stored rows do not move because source metadata did |
The dialect-gate row is the one that bites during review, because it turns a compiling program into a failing one with no source edit. Move this model from a schema routed to PostgreSQL to one routed to the SQLite default and the array column stops compiling:
schema seaport
model Vessel {
@id
id: int
tags: []string
}
fn f(): []Vessel ! QueryError {
return FROM Vessel?
}That reports ATOLL3232. Re-check the whole project after a routing change
rather than assuming the source is engine-neutral, and verify both the rendered
migration and the live-data movement before deploying a schema rename.
Generated DDL
The compiler builds a desired schema snapshot from the registered models and
renders per-dialect CREATE TABLE, foreign-key, and index statements;
atoll migrate diff compares that snapshot against an introspected live
database. A model with a BELONGS TO ... VIA and an index renders all three
kinds of statement together:
schema seaport
model Schedule {
@id
id: int
capacity: int
}
model Booking {
@id
id: int
status: string
schedule_id: int
BELONGS TO schedule: Schedule VIA schedule_id
INDEX (status)
}
fn queued(): []Booking ! QueryError {
return FROM Booking WHERE status == "queued" ORDER BY id?
}
fn main(): void {
match queued() {
Ok(rows) => println("${rows.len()} queued")
Err(e) => println("read failed")
}
}CREATE TABLE schedule (
id BIGINT NOT NULL,
capacity BIGINT NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE booking (
id BIGINT NOT NULL,
status TEXT NOT NULL,
schedule_id BIGINT NOT NULL,
PRIMARY KEY (id),
CONSTRAINT booking_schedule_id_fkey FOREIGN KEY (schedule_id) REFERENCES schedule (id)
);
CREATE INDEX booking_status_idx ON booking (status);Referenced tables are emitted before the tables that reference them, and constraint and index names are derived from the model and column names — so renaming a field renames its constraint too.
Because temporal and alias types use wire-faithful storage mappings, reverse introspection recovers storage shape rather than every original domain type. Read a generated migration as a database change, not as a lossless round trip of your source.
For the route that a schema name selects — and the difference between schema, route, engine, URL, and host capability — continue to Datasources.