Skip to content

Datasources

Bind a schema to an engine and connection, and see what routing changes in your source.

Updated View as Markdown

A datasource binds a logical schema to a database engine and connection. Query source never names a connection; the binding lives in atoll.toml:

# `module` is the one always-required manifest field: the module root every
# local import prefixes from. Without it the project does not load at all.
module = "example.dev/shop"

[datasources.PRIMARY]
schema = "shop"
engine = "PostgreSQL"
url = "${DATABASE_URL}"
pool = { min = 5, max = 20 }

[datasources.REPLICA]
schema = "shop"
engine = "PostgreSQL"
url = "${REPLICA_URL}"
readOnly = true

The Atoll side of that contract is just the schema name:

schema shop

model Product {
    @id
    id: int
    sku: string
    price: float
    discontinued: bool
}

fn catalog(ceiling: float): []Product ! QueryError {
    return FROM Product
        WHERE discontinued == false
        WHERE price <= ceiling
        ORDER BY price ASC, id ASC?
}

fn main(): void {
    match catalog(19.99) {
        Ok(rows) => println("${rows.len()} in catalog")
        Err(e) => println("read failed")
    }
}

Because Product belongs to shop, and [datasources.PRIMARY] serves shop, this query renders in the PostgreSQL dialect and runs on PRIMARY’s pool.

Manifest fields

Key Required Meaning
schema yes the logical schema this datasource serves
engine yes one of PostgreSQL, MySQL, SQLite, Turso, DataFusion, DuckDB
url yes connection URL, after ${VAR} substitution
readOnly no when true, integrated writes to this route fail at compile time
pool no { min, max } connection-pool bounds

Engine names are case-sensitive and validated while atoll.toml is parsed. So are the other constraints: an unknown engine, a missing ${VAR}, a URL that substitutes to the empty string, and a pool with min > max are all configuration errors, not runtime surprises.

Where the manifest is found

Routing is not a build-mode-only feature. The compiler discovers atoll.toml by walking up from the file being checked, so both of these see the same route:

atoll check .                  # project mode
atoll check src/catalog.at     # single file, manifest found by walking up

The consequence is that a file’s route — and therefore its dialect gate and its read-only write gate — depends on where the file sits on disk, not on which command you ran. Every example in this guide is checked in a scratch directory with no manifest above it, which is why they all render SQLite.

Selection

Routing is a lookup on the queried model’s schema:

  1. use the first configured datasource whose schema equals the model’s;
  2. otherwise, if exactly one datasource is configured, use it;
  3. otherwise fall back to the SQLite dialect and the default route identity.

Rule 3 is why every example in this guide compiles with no atoll.toml at all. It is also why an unmatched schema in a deployable application should be treated as a mistake during review: the program will compile and target no intended connection.

Give each logical schema exactly one authoritative datasource. Duplicate entries for one schema resolve deterministically to the first, but the intent is ambiguous.

What the engine changes

The selected engine is not a cosmetic detail. It picks a dialect and a set of feature gates, and an unsupported construct is rejected during compilation with ATOLL3232. On the SQLite default, a locking read is refused:

schema shop

model Product {
    @id
    id: int
    stock: int
}

fn reserve(product: int): { id: int, stock: int }? ! QueryError {
    return ONE FROM Product WHERE id == product SELECT { id, stock } FOR UPDATE?
}

So is a native array column:

schema shop

model Product {
    @id
    id: int
    tags: []string
}

fn f(): []Product ! QueryError {
    return FROM Product?
}

Route that schema to PostgreSQL and both compile unchanged — the source is identical, only the manifest differs. Advanced functions, index methods, full-text search, vector operations, locking, and window support are all engine-gated in the same way.

Dialect and executor coverage differs:

  • PostgreSQL, MySQL, SQLite, Turso, and DataFusion have distinct render paths.
  • Turso is deliberately not an alias for SQLite: it lacks WITH RECURSIVE and some window functions, and it has a full-text index method plain SQLite has no equivalent of.
  • DuckDB currently falls back to SQLite rendering rather than a dedicated executor contract.

The same query text can therefore be portable and still not be uniform: read the engine’s gate list before assuming a construct survives a routing change.

Read-only routes

readOnly = true is a compile-time write gate. A write against a model served by a read-only route is rejected with ATOLL3231: write to 'Product' targets a read-only datasource. The source below is fine against a writable route and fails against a read-only one, without any change to the Atoll code:

schema shop

model Product {
    @id
    id: int
    price: float
}

fn reprice(product: int, amount: float): int ! QueryError {
    return UPDATE Product SET price = amount WHERE id == product?
}

fn stocktake(): []Product ! QueryError {
    return FROM Product ORDER BY id?
}

fn main(): void {
    // Reads pass on either route; the write only compiles on a writable one.
    match stocktake() { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
    match reprice(1, 4.5) { Ok(n) => println("${n} repriced") Err(e) => println("failed") }
}

The manifest flag protects checked query source only. It is not a database credential, and it does not constrain raw host integrations. For a production read replica, use all three layers:

  1. readOnly = true, so integrated writes fail during compilation;
  2. database credentials without write privileges;
  3. routing and monitoring that identify the replica independently of the primary.

Reads still pass their own feature gates. A read-only route does not gain write-transaction participation just because its engine supports transactions.

Route prefixes

A binding may carry a datasource name between the binding and :=:

schema shop

model Product {
    @id
    id: int
    sku: string
}

fn listing(): []Product ! QueryError {
    rows REPLICA := FROM Product ORDER BY id?
    return rows
}

fn store(product: int, sku: string): Product ! QueryError {
    saved PRIMARY := SAVE Product { id: product, sku: sku }?
    return saved
}

fn main(): void {
    match listing() { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
    match store(1, "A-1") { Ok(p) => println("saved ${p.sku}") Err(e) => println("failed") }
}

The parser records the prefix, and both functions compile. The semantic query path does not consume it yet — dialect and read-only policy still come from the queried model’s schema, so a REPLICA-prefixed write would not be caught by that route’s readOnly flag. Treat the prefix as documentation of intent, not as an override, until named-route validation and runtime selection are wired end to end.

Routes are not URLs

Several values are easy to conflate:

Value Purpose Set by
Model schema which logical database owns a model schema NAME in source
Datasource name labels one manifest entry [datasources.NAME]
Route identity keeps a transaction’s queries together compiler routing
Engine dialect and feature gates datasource engine
URL which deployment to connect to datasource url
Host capability permits the runtime SQL operation deployment host policy

Equal engines do not imply equal route identity, and equal URLs do not merge two configured entries. Two schemas that both fall back to the default share one route identity; two separately matched entries do not, even with identical engine and URL.

Route identity is what a transaction checks: every database operation inside one transaction block must resolve to the same route, even when the models sit in different schemas.

Not a source-level declaration

Older material shows a datasource constructed in Atoll source. That is not a supported form — the expression is not const-evaluable, and datasource and PostgreSQL are not names that resolve:

const PRIMARY = datasource(PostgreSQL, "postgres://localhost/shop")

That reports ATOLL1019: expression is not const-evaluable plus two ATOLL1009: unresolved name diagnostics. Keep credentials and deployment topology in atoll.toml, where ${VAR} substitution keeps secrets out of the repository.

Prepared statements

Routing happens before query lowering, because the dialect determines the rendered SQL. The compiler then renders fixed statements, registers them by identifier, and emits parameter binding through the SQL ABI. Runtime data fills placeholders; it never contributes SQL text.

That ordering has one practical consequence worth remembering: changing a schema’s engine can turn a compiling program into a failing one, because it changes which feature gates apply. Re-check the project after a routing change rather than assuming the source is engine-neutral.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close