Zornux docs
Get started Spec

Data & Enterprise

Data Layer

Zornux has a built-in ORM — no external library, no SQL strings. You declare a database, map your classes to tables, and read and write with plain-English statements. Queries are parameterized and injection-safe by construction.

Declaring a database

A database … end block names a provider and maps one or more classes to tables with table T from Class:

zornux
class User
    has id
    has name as text
    has email as text required     # NOT NULL
    has balance as money           # exact minor units, lossless
end

database AppDb
    provider memory
    table Users from User
end
Providers

provider memory is an in-memory store — perfect for tests and prototyping. provider sqlite persists to a file, and provider postgres backs production, multi-instance deployments. All three ship in the standard runtime and work out of the box — PostgreSQL needs no special build or flag (its managed driver is bundled into every official release; it is omitted only from a browser-wasm build). The ORM syntax is identical across all three, so the same code moves from memory in tests to postgres in production. Connection details (and secrets) are supplied through configuration, never hard-coded.

Typed & nullable columns

A field may declare a column type — text, number, whole, truth, date, datetime, money — and required for NOT NULL (enforced pre-save, ZX2422 on violation). A typed column round-trips as that exact kind on every provider, where an untyped truth/date may read back as a number or string on a SQL store. as money persists a MoneyValue losslessly (integer minor units, currency preserved) on both SQLite and PostgreSQL. Untyped has name still works — a type is opt-in.

Save, find, delete

save item into Table inserts a new item (assigning a fresh id) or updates an existing one, matched by its id. find queries; delete removes.

zornux
create alice from User
alice.name = "Alice"
alice.email = "alice@example.com"
save alice into AppDb.Users

# Read every row, or the first match of a condition.
show "Users: " + text(length(find all from AppDb.Users))
create found = find one from AppDb.Users where email is "alice@example.com"
show "Found: " + found.name

# Update: change a field and save the same item again.
found.name = "Alice Jr."
save found into AppDb.Users

# Delete.
delete found from AppDb.Users
StatementDoes
save item into Db.TableInsert (new id) or update (by id).
find all from Db.TableEvery row, as a list.
find one from Db.Table where field is valueThe first match, or nothing.
delete item from Db.TableRemove an item.
How many rows changed

save and delete can report the affected-row count with giving: save item into Db.Table giving written (0 or 1) and delete item from Db.Table giving removed. update … in Db.Table where … applies an atomic compare-and-set and gives the number of rows it changed.

Rich queries

A where clause is a tree of comparisons joined by and / or. The is … operators reuse the language's own comparison words, so a query reads like any other condition:

zornux
find all from Db.People where age is greater than 18
find all from Db.People where city is "London" and age is greater than 30
find all from Db.People where name starts with "A" or name ends with "z"
OperatorMatches
is / is notEquality / inequality.
is greater/less than [or equal to]Ordering (numbers or text).
contains / starts with / ends withText matching.
Injection-safe, still

Queries run over parameterized rows, never string-concatenated SQL. An unknown field is a diagnostic, and untrusted input in a condition is rejected — the guard checks the whole and/or tree, so nothing slips in through a nested branch.

Ordering & pagination

Sort with sorted by field [descending] (more keys with then by, a stable sort), and page with skip N (offset) and first N (limit):

zornux
find all from Db.People sorted by age descending
find all from Db.People sorted by city then by age
find all from Db.People sorted by created descending skip 20 first 10

Aggregates & existence

Ask a question instead of fetching rows: find count, find any, and the numeric aggregates sum / average / minimum / maximum of field from:

zornux
find count from Db.People where city is "London"
find any from Db.People where age is greater than 100
sum of age from Db.People
average of age from Db.People where city is "London"
No new keywords

Every query word is contextual — sorted, skip, first, count, sum, and the rest — so create first = 1 and create sum = 2 still bind ordinary variables. An empty sum is 0; an empty average / minimum / maximum is nothing.

Queries run in the database

On a SQL provider, a find — its where, ordering, paging, find count/find any, and the aggregates — is compiled to the provider's own SQL and executed in the store, not fetched-then-filtered in process. The result is byte-identical to running it in-process, so the same query is correct on memory, SQLite, and PostgreSQL.

Relationships — has one / has many

A class declares a relationship with has one / has many Target by column — it maps to no column. Load it with find … with, which batch-loads related rows one query per level (WHERE … IN (…)), so there is no N+1. SQL joins are intentionally not exposed.

zornux
class Customer
    has id
    has email as text required
    has many Order by customer_id
end

class Order
    has id
    has customer_id as whole
    has total as money
end

# Load customers and their orders in one extra query — not one per row.
create rows = find all from Shop.Customers with orders

Transactions

A transaction is all-or-nothing: it commits when its body finishes cleanly and rolls the whole batch back if a recoverable error occurs — the same catch error recovery as error handling.

zornux
transaction on Bank
    save ada into Bank.Accounts
    save grace into Bank.Accounts
    show item_at([], 3)        # fails — the whole batch rolls back
catch error
    show "rolled back (" + error.code + ")"
end

Injection-safe by design

Untrusted input can't flow straight into a query. A value from read_text or a web request is UntrustedText until you sanitize or validate it — passing raw untrusted text to the database raises ZX2411. Queries are parameterized under the hood, so there is no string-concatenation attack surface.

The database never sees a secret in the clear

Connection strings and credentials come from configuration secrets; a failed connection error never prints them.

Schema migrations

As classes change, the database has to keep up. Zornux uses a hybrid model: the automatic diff adds any new tables and columns the classes declare, while migration … end blocks handle the rest — renames and removals — ordered and recorded so each runs once. The diff never removes anything, so a deploy can't lose data by surprise.

zornux
migration CreateProducts
    create table Products from Product
end

migration AddSku
    add field sku to Products
end

migrate Store      # apply pending migrations, then the additive diff
rollback Store     # reverse the most recently applied migration
StepEffectReverse
create table T from Classcreate the tabledrop the table
add field f to Tadd a columndrop the column
rename field a to b in Trename a column, keeping datarename it back
remove field f from Tdrop a columnirreversible (ZX3505)
Deployment-safe & reversible

migrate applies every pending migration and the additive diff in one transaction — a failure part-way rolls the whole thing back. A database can also migrate on open. From the CLI, zornux db status lists applied/pending, zornux db check gates a deploy by flagging drift (a column the classes no longer declare, ZX3504), and zornux db migrate / rollback drive it. migration, migrate, and rollback are all contextual — no new reserved words.

Least-privilege runtime — --no-migrate

A production worker can run with --no-migrate so it opens the database without migrate on open or lazy table creation — the runtime login then needs no DDL rights at all. Schema is applied ahead of it by a separate, privileged zornux db migrate step in the deploy pipeline.

Async in components

In a real backend, data access lives in a repository whose methods are async functions — a service calls them with wait for:

zornux
repository UserRepository
    async function all
        give back find all from AppDb.Users
    end
end

Diagnostics are ZX2400ZX2499. Next: layering services and injection on top — Application Layer.