Reference

Standard library

A builtin given more arguments than it takes stops and says what it takes: [`split`](/docs/reference/split/) takes 2 arguments, got 3: split(s, sep) (and for sort given a comparator, to use sort_by), in quillo check where the call is written out, as the program runs however it is called (a method on an untyped value, line.split(",", 2); a builtin held in a variable or passed to another, reduce(xs, 0, max)), and in the browser. Nothing is ignored: uuid(1), random(1) and merge(a, b, c) are errors.

What each does at its edges, where the answer is not what another language would give:

CallGives
sort(xs), sort_by(xs, f)ascending and stable; f may give a list to sort by several keys, sort_by(rows, (r) => [r.a, r.b]), as lists compare item by item, and so do <, >, <= and >= ([1, 2] < [1, 3]); no comparator and no descending flag (reverse(sort(xs)), or vec.argsort(xs, true)). Numbers (ints, floats and decimals together), strings (by code point: "B" before "a") and lists of those order; null, booleans, maps, a string with a number, a decimal with a float, or NaN are an error, in the browser too (vec.argsort puts NaN last), and comparing null says that a miss of index_of, find or get is null, not -1
split(s, sep = " ")at each single sep ("a b" is ["a", "", "b"]); split(s, "") is the characters; no limit
join(xs, sep = "")the items as text, with sep between
filled(x)x, or null for "", so filled(name) ?? "anonymous" replaces a blank or missing name; 0, false, [] and " " stay as they are (filled(trim(x)) counts spaces as blank)
slice(x, start, end)negative positions from the end; both kept within the list or string; end before start gives an empty one
take(x, n), drop(x, n)a negative n counts as 0, unlike slice: take(xs, -2) is empty, drop(xs, -1) all of it, so a count worked out by subtraction stays safe
range(n), range(a, b), a..b, range(a, b, step)b left out, step 1, empty when b <= a; a negative step counts down; a list of at most ten million, while a for whose list no one keeps counts without one
group_by(xs, f)a map from each key, as text, to the items with it, in order: 1 and "1" are one key; an error f gives is given back, not made a key
unique(xs)the first of equal items, keeping exactly what == keeps (unique([1, 1.0, 1m, "1"]) is [1, "1"]), in time linear in the list on both sides: 40,000 items take milliseconds, not seconds
zip(a, b)pairs, as long as the shorter
contains(x, y)in a string, y as text; in a list, an equal item; in a map, a key
remove(list, x)every item equal to x gone; remove(map, k) the key
set(xs, i, v)i past the end is an error
round(x)a float rounds half away from zero (round(-2.5) is -3) to an int, up to 2^63 (type_of(round(1e16)) is int; past it, and NaN and the infinities, the float), and an int is itself (exact past 2^53); round(x, 2) a float with at most 15 places, from its shortest decimal (round(1.005, 2) is 1.01, as format_number writes it); fewer than no places round to tens, hundreds, ...: round(1234.5678, -2) is 1200.0, from the float's shortest digits and exact at every place (round(1e30, -29) is 1e30), and a result past the largest float is an error; an int to 0 places or more is itself (round(5, 2) is 5, as in Python), and to fewer the nearest multiple, exactly, a half away from zero (round(1250, -2) is 1300, where Python's is 1200, half to even); round(a / b) of two ints is exact (see / under Values); a decimal takes a mode (see Decimals)
int(x)a float or decimal cut toward zero (an error value when too large for an int); true is 1; text of digits only (int("1.0"), int("1e3") are error values)
float(x)a number as a float; text as Rust reads a float ("2.5", " 7 ", "1e3", ".5", "inf", "nan"), so "0x10", "0b11", "1_000" and "" are error values, in the browser too
floor(x), ceil(x)a float to an int, up to 2^63 (type_of(floor(1e16)) is int; past it the float); an int as it is, past 2^53 too, as round(x) gives it; floor(a / b) of two ints divides in ints, exactly (see / under Values)
str(x)a whole float as 2.0 (in a component's text, 2, on both sides), never in exponent form (1e16 is 10000000000000000); a float's shortest digits, a tie taking the even digit as JavaScript does (1916218016406818.3, which is held as ...818.25, is written 1916218016406818.2); markup as its HTML, in the browser too; a list or map as Quillo writes it ({ a: 1, b: "x" }, a key of letters, digits and _ of any script, not starting with a digit, bare ({ é: 1, 日本: 2 }), another quoted ({ "a b": 3, "1a": 4 }), on both sides); null as null
type_of(x)one of null bool int float dec str list map fn html response error (promise for a JavaScript Promise in the browser)
len(x)a string's characters, a list's items, a map's keys; an html value's bytes
min([]), max([]), first([])null; sum([]) is 0, and a sum of decimals stays exact; sum adds numbers only, and text stops it at its item ([`sum`](/docs/reference/sum/) adds numbers; got the str "12.50" at item 1: use [`float(...)`](/docs/reference/float/) or [`dec(...)`](/docs/reference/dec/) first), where it was joined after a 0
sqrt(-1)NaN, while math.sqrt(-1) is an error, as in Python
pow(2, 10)an int, exact, or an overflow as * gives (pow(2, 63), and pow(2, 4294967296): a power of 2^32 or more is exact for a base of -1, 0 or 1, and an overflow for any other); a float on either side, or a negative power, gives a float; a decimal to an int's power, a decimal (see Decimals)
print(a, b)the values as text, a space between, on stdout (the console in a page)
now()milliseconds since 1970, an int
sleep(ms)no maximum; other requests run meanwhile, and a request's sleep of 5 ms or more holds no thread (a timer wakes it), so a hundred streams sleeping in their loops hold up nothing; in a scheduled job or a tick, ends it (status 503) when the server stops or the program is loaded again, so a loop that runs for ever stops; a request waits it out (it is answered at a stop, up to 10 s, and finishes on its version at a reload)
random_int(lo, hi)both ends included
uuid()a version 4 UUID
read_file(path)text: bytes that are not UTF-8 are replaced; a missing file is an error value
write_file(path, v)v as text, folders made, the file replaced; null or an error value
escape(s)& < > " ' as entities
json.parse(s)an error value for bad JSON, for a number past a float (1e400) and for half of a character (a lone \ud83d), on both sides; an int past 64 bits becomes a float; a key given twice keeps the last value; keys in the order written, on both sides; at most 128 levels deep, on both sides, past which it is invalid JSON: it nests more than 128 deep (request.json and an http answer's json are null, a stream's line is its text, and a database's JSON cell is that error); every other reader of JSON (sessions, channels, a server fn's arguments, a service's body) takes the same depth
json.stringify(v, pretty)indented by two spaces when pretty is true; a whole float as 2.0 (-0.0 too), so it reads back as a float, and an exponent where JavaScript writes one (1e+21, 1e-7), on both sides; markup as its HTML; a decimal as a string, NaN and infinities as null, an error value as {"error": ...}; a function is an error value naming where it is (json.stringify: `a.f` is a function, which cannot be written as JSON), where it was written as text; JSON past 256 MiB, or nested more than 10,000 deep, is an error value, counted before it is made, as lists that hold one part twice can double at each step (since 0.7); a route's answer the same, a 500 with the reason in the log

What is not in the standard library, and what to do instead:

Safety built in:

Values are immutable: push, set and + return new lists and maps. A map's keys are text: set(m, 5, x) keys it "5", and m[5], get(m, 5) and has(m, 5) read that. set(xs, i, v) gives a list with item i replaced (from the end when negative). A map or list built in a loop with m = set(m, k, v) (or push, merge, remove), or as reduce's total with (m, x) => set(m, ...), is changed in place, since nothing else holds it: building one of 20,000 takes milliseconds. It is the same however it is written, m = m | set(k, v), m = m.set(k, v), xs += [x] or xs = xs + ys, at a script's top level too, and for what is inside: m = set(m, k, get(m, k, []) | push(x)) (a map of lists) and g = set(g, y, set(g[y], x, v)) (a grid) change the inner list where it is, with a key made by str, lower, trim or int too, as those give the same written twice. s += t adds to the text where it is. Where something else still holds it (a second name for it, say), each step copies it, so that name keeps what it had. In the browser a list's push and set always copy, so build a long list there with for, or flatten chunks. flatten takes one level off: flatten(flatten(xs)) for two.

read_file, write_file, delete_file, file, files, save_upload and check_program take a path from the program's folder, and stay in it. A path with .. or starting with / is refused, unless the program is started with --allow-path naming the folder it leads to (quillo run main.qwe --allow-path /srv/shared/uploads). A path is judged where it really leads, with its links followed:

What is refused is what any refused path is: an error value from read_file, write_file and delete_file (read_file: path must stay inside the program's folder: data/s.txt), a 404 from file and files (the same whether the folder outside is there or not), and a runtime error from save_upload.

// alias -> real (inside), data -> ../outside, dangling -> ../outside/gone.txtprint(read_file("alias/in.txt"))                 // innerprint(read_file("data/s.txt") ?? "refused")      // refusedprint(write_file("dangling", "x") | is_err)      // true

Only the path's parts are looked at, and only a path with a link in it is resolved, so a static file costs what it did. A link changed between the check and the open is not caught: Quillo does not use Linux's openat2.

Files of a program

use lib/forum.qwe brings another file into the program, and its names into this file. import (since 0.7) takes some of them. A file sees its own names, the builtins, and what it takes:

// ui/thread.qweuse lib/forum.qwe                         // every public name of the fileimport lib/forum.qwe use *                // the sameimport lib/forum.qwe use latest, Feed     // only theseimport lib/forum.qwe use latest as newest // this one, called `newest` in this file

The file comes first, and the first word says what kind of thing it is, for three reasons:

  1. Mental model. The line reads in the order things happen: what kind of code this is, where it is, then which of its names you want. import is Quillo or Rust on the server; client, preload and preconnect are the browser, and the word says how the page gets the script. One glance at the start of a line tells you which side of the program you are on, with nothing to read from the end of a long address.
  2. Editor. Once the file is written the editor knows its names, so it can offer them as you type, and hide those you already took. With the names first, as import { a } from "x" has them, there is nothing to offer until the path is there. The word is import rather than from for the same reason: from starts lines of SQL, and programs use it as a name.
  3. Readability. Lines line up by where things come from, so a file's sources read down the left. An address can be long, and the names no longer sit after it at the far right: use may take the next line.

0.6's use latest, Feed from lib/forum.qwe still works, with a warning; quillo fix turns it around. A later release removes it.

Decimals

A dec is an exact decimal number, for money and whatever else is counted in cents: 12.50m, 5m, -0.01m (the m is C#'s). A float is a binary fraction, so 0.1 + 0.2 is 0.30000000000000004; decimals are exact, so 0.1m + 0.2m == 0.3m.

let price = 19.99mlet total = price * 3 + 4.50m          // 64.47mlet vat = round(total * 0.21m, 2)      // 13.54m (half to even)let each = total / 3                   // 21.49mdec("12.50")  dec(42)  dec(0.1)        // 12.50m, 42m, 0.1m

Rounding. Each place that rounds says how. A tie, the 5 of 0.125 to two places:

WhereA tie0.1251.005
money.*, and round(d, n) of a decimalto even, unless a mode says otherwise0.121.00
format_number(x, locale, n)away from zero, from the shortest decimal, as Intl.NumberFormat; or mode0.131.01
round(x, n) of a floataway from zero, from the float's shortest decimal (since 0.7: 1.005 was 1.0)0.131.01
money.format, where a currency shows fewer decimals than it has (IRR)away from zero, as Intl.NumberFormat
PostgreSQL numeric(p, s) and MySQL decimal when a value has more decimalsaway from zero0.131.01

So write a line of an invoice as money works it out: format_number(x, locale, 2, "half_even"), or money.format of the line's money, never format_number of the amount with its default, which may show a cent more than the total adds up.

In databases. PostgreSQL's numeric and MySQL's decimal come back as decimals with their scale (numeric(10, 2) gives 19.99m, and 12.50 stays 12.50m), and decimals are sent exactly. A value without a scale (as sum of ints gives) is an int, and one past 28 decimals or 29 digits is its exact text. In MySQL a decimal parameter arrives as text, which MySQL turns exactly into a decimal column, but into a double inside an expression: write cast(? as decimal(12, 2)) there.

SQLite has no decimals. A decimal is stored as its text, and a column declared decimal(12, 2), numeric(12, 2) or money is read back as decimals (a bare numeric, SQLite's name for any number, gives numbers as before). But such a column has NUMERIC affinity: SQLite turns "0.10" into the real 0.1 (and rounds past 15 digits), so the scale is lost and large amounts are not exact. For exact amounts, declare the column decimal_text (TEXT affinity: SQLite keeps the digits as written, and Quillo still reads them as decimals), or keep whole minor units (amount_cents integer; see money.to_minor). SQL's own sums and ordering do not work on text: max of a decimal_text column is the largest text ("9.99" before "100.00"), as text; add up and sort in Quillo, or keep minor units. The first time SQLite is given a create table or alter table with a money, decimal, dec(p, s) or numeric(p, s) column, the log says which column, once (since 0.7).

PostgreSQL's own money type keeps a count of the smallest unit of its lc_monetary's currency. Quillo reads it with that currency's decimals, asked of the connection the first time a result has one: 500 written reads back as 500m under a yen lc_monetary, and 500.00m under a dollar one (since 0.7: it was always read as cents, so ¥500 came back as 5.00m). It carries no currency: numeric(p, s) and a currency column say more.

A list given as a query's parameters, db.exec("insert ... values (?)", [x]) as Python's sqlite3 takes them, is one parameter, stored as the list's JSON text. Parameters are separate arguments: db.exec(sql, a, b). quillo check says so when the SQL is written out and has a ? (since 0.7), but not where a list is PostgreSQL's array: a ? in any(...) or all(...), or cast to an array type (?::int[], ?::jsonb[]).

Money

money keeps amounts of a currency: a map { amount: dec, currency: "USD" }, the amount always with the currency's minor units (ISO 4217: JPY 0, USD 2, BHD 3). It is a plain map, so JSON, the database and the browser take it as it is, and money checks it each time it is given one. On the server and in the browser alike:

let price = money.of(19.99m, "USD")             // { amount: 19.99m, currency: "USD" }money.of(form.amount, "EUR") ?? reply 400       // text from a form; an error value if badmoney.parse("1.234,50 €", "EUR", "de-DE")       // 1234.50, as a German visitor writes itmoney.of("12.345", "USD")                       // err: more decimals than USD hasmoney.of("12.345", "USD", "half_up")            // 12.35money.from_minor(1250, "USD")                   // 12.50, from centsmoney.to_minor(price)                           // 1999price + tax  price - discount                   // amounts of one currency, addedmoney.add(a, b)  money.sub(a, b)  money.neg(a)  money.compare(a, b)   // -1, 0, 1money.mul(price, 3)  money.mul(price, 0.075m, "half_up")              // rounded to centsmoney.allocate(money.of(100, "USD"), [1, 1, 1])  // 33.34, 33.33, 33.33money.format(price, "de-DE")                    // "19,99 $"

To hold what a client sends to money's rules, name the type money: in a pattern, route POST /pay { json: { price: money } }, a record's field, type Line = { item: str, price: money }, or an annotation, fn total(m: money). In a pattern or a field it takes a map of an amount (a decimal, an int, numeric text or a JSON number) and a currency, holds it to money.of's rules with no rounding mode, and gives the money; a request that fails gets a 400, and the log (or the page, under --watch) says which rule, without the value sent: "json.price has more decimals than JPY has (0)". In an annotation it takes money as the money functions take it. A record written out, type Money = { amount: dec, currency: str }, checks only the shape (since 0.7: that was the way, and $10.004 went through to be cut to $10.00, "1e3" was 1,000, and BTC a 500).

Numbers and data

math is Python's math module, stats its statistics module with what a data frame's describe adds, and vec numpy's operations on lists of numbers. They are on the server and in the browser alike, so a component can chart what a route computed:

let totals = vec.column(orders, "total")          // [12.5, 30, 7.5, ...]stats.describe(totals)        // { count, sum, mean, std, min, q1, median, q3, max }stats.quantiles(totals, 10)   // deciles, as Python computes themstats.correlation(ads, sales) // Pearson; "ranked" for Spearmanstats.linear_regression(ads, sales)   // { slope, intercept }stats.histogram(totals, 20)   // { counts, edges }, as numpy'svec.rolling_mean(daily, 7)    // a week's moving averagemath.comb(52, 5)  math.log(8, 2)  math.fsum(xs)  math.isclose(a, b)  math.pi

Answers are Python's: sums are compensated and rounded once, so stats.mean, stats.variance and math.fsum give Python's exact results (its statistics module uses fractions; this gets the same float without them), and the rest agree to the last digit or one or two places from it. None of it runs in the interpreter: a list is read once into floats, and a long one (over 128K items) is worked on by all the cores, in chunks of 32K whatever the machine, so the answer does not depend on how many cores it has. A median, a percentile or a quantile sorts only the part of the list where it falls (cut points from a sample split the list into ranges of about equal size), and vec.argsort, and sort of a list of 64 or more items that are all ints or all floats (no NaN), sort those ranges side by side; any other list sorts on one core. There is no matrix or data-frame type: rows are lists of maps, vec.column takes a field of each, and vec.matmul and vec.transpose work on lists of lists. On a million floats, against Python 3.9 (no numpy) on the same 10-core machine:

QuilloPython
stats.mean0.35 ms214 ms
stats.stdev0.65 ms674 ms
stats.median1.3 ms44 ms
stats.quantiles(xs, 100)2.0 ms45 ms
stats.correlation1.4 ms246 ms (3.10's algorithm)
stats.linear_regression1.3 ms182 ms (3.10's algorithm)
stats.describe1.7 ms764 ms (by hand)
stats.histogram(xs, 50)0.6 ms126 ms (a loop)
math.fsum1.6 ms20 ms
vec.dot0.9 ms11 ms (sum(map(mul, x, y)))
vec.add1.9 ms20 ms (list(map(add, x, y)))
vec.rolling_mean(xs, 30)2.0 ms69 ms (a running sum)
vec.argsort5.4 ms75 ms (sorted(range(n), key=...))
sort4.6 ms46 ms

Making a list one item at a time is the interpreter's work, though: map(range(n), (i) => ...) over a million takes about 1.4 times Python's list comprehension. Built with vec instead (vec.div(vec.mod(vec.mul(vec.arange(n), k), m), 1000.0)), the same list takes a fifteenth of the comprehension's time.

Dates and times

There is no date type: a date is its ISO 8601 text, "2026-10-27", and a date-time is RFC 3339 text with its offset, "2026-10-27T14:30:00Z" or "2026-10-27T15:30:00+01:00". Dates sort and compare in time order as text. Date-times do only when they are written the same way, with the same offset and the same digits: "…14:30:00.000Z", as JavaScript's toISOString() writes it, sorts before "…14:30:00Z", though they are one instant. To order or compare date-times, use date.compare(a, b) or date.ms. Both go through JSON, databases, forms and the browser as they are. The date module works on it, on the server and in the browser alike, with the same answers:

date.today()                          // "2026-10-27", in UTC; date.today("Europe/Paris")date.now()                            // "2026-10-27T14:30:00Z", to the seconddate.add_months("2026-01-31", 1)      // "2026-02-28": the end of the month, not Marchdate.to_zone("2026-10-27T14:30:00Z", "Europe/Paris")       // "2026-10-27T15:30:00+01:00"date.at("2026-10-27", "17:00", "America/New_York")         // "2026-10-27T17:00:00-04:00"date.at("2026-10-27T17:00", "America/New_York")            // the same, from <input type="datetime-local">date.compare("2026-10-27T14:30:00.000Z", "2026-10-27T15:30:00+01:00")   // 0: one instantdate.days_between("2026-11-05", "2026-11-14")              // 9: the nights of a staydate.format("2026-10-27", { locale: "de-DE", style: "long" })  // "27. Oktober 2026"date.parse("27/10/2026", "DD/MM/YYYY")                     // "2026-10-27", or an error value

A malformed date ("2026-02-30", "27/10/2026", a number) is an error in every function but date.parse and date.valid. Years are 1 to 9999. date.today and date.now read the clock, so they are the time effect, and scripts run with run_script have the rest of date but not them. A program's own variable called date hides the module, as with the others.

What a form's date and time inputs send, and what reads it:

InputSendsRead it with
<input type="date">2026-10-27as it is: it is a date
<input type="time">14:30date.at(day, v, zone)'s second argument
<input type="datetime-local">2026-10-27T14:30date.at(v, zone)
<input type="month">2026-10no date function reads it: split it, date.of(y, m, 1)
<input type="week">2026-W44no date function reads it

None of them carries a zone: the program says which (a user's setting, the tz cookie below).

Business days, holidays and the day counts of interest are a program's own: a calendar is a list of holidays and the arithmetic above.

The server does not know a visitor's time zone: date.today() is UTC's. A page tells it with a cookie, set from Intl.DateTimeFormat().resolvedOptions().timeZone through js, which a helper reads with cookie("tz"); the first page a browser asks for is made before the cookie exists.

fn zone() -> str = cookie("tz") ?? "UTC"     // in any function the request runsserver fn set_zone(zone: str) =    date.valid_zone(zone) ? set_cookie("tz", zone, { max_age: "1y" }) : nullcomponent SendZone(known) {            // once in the layout: sends the zone when it differs    <span hidden onmount={() => {        let z = js("Intl.DateTimeFormat", []).resolvedOptions().timeZone        if z != known { set_zone(z) }    }}></span>}route GET / = <html><body>    <SendZone known={cookie("tz")}/>    <p>Today, where you are: {date.today(zone())} ({zone()})</p>    <form method="post" action="/meet">        <input type="datetime-local" name="at"/>        <button>Book</button>    </form></body></html>route POST /meet { form: { at: str } } = `Booked for ${date.at(at, zone())}`

In Manila the first page says (UTC); from the next one on, Today, where you are: 2026-11-02 (Asia/Manila), and 09:30 booked in the form is Booked for 2026-11-20T09:30:00+08:00. set_zone checks the zone, since anyone can call it with any text.

Database

db talks to SQLite (built in), PostgreSQL or MySQL/MariaDB, with the same functions. SQL problems are error values, so ? and ?? work on them. db is a library, not syntax: other stores (Redis, MongoDB, ...) come in through Rust (see Other stores).

db.open("app.db")                                   // SQLite, next to the program; ":memory:" also worksdb.open("postgres://user:pass@host:5432/app")       // PostgreSQLdb.open("mysql://user:pass@host:3306/app")          // MySQL or MariaDB (also mariadb://)db.exec("create table if not exists todos (id serial primary key, text text)")db.exec("insert into todos (text) values (?)", text)       // { changes, last_id }db.exec_many("insert into todos (text) values (?)", [["a"], ["b"]])   // { changes: 2 }, all or nonedb.query("select * from todos where id > ?", 10)           // a list of mapsdb.one("select * from todos where id = :id", { id })       // a map, or null; keys the SQL does not use are ignoreddb.one("insert into todos (text) values (?) returning id", text)db.transaction(() => { ... })                              // all or nothing; nestsdb.transaction({ isolation: "serializable", retries: 3 }, () => { ... })

Without db.open, the database is $QUILLO_DB (a path or a URL), or else SQLite in memory. A transaction rolls back if its function fails, returns an error value or replies early, and also when a statement in it gives an error that nothing takes: db.exec(...) alone on a line that fails rolls the whole transaction back, and the transaction gives that error as its value, so half the work is never committed. A transaction SQLite ends by itself (a full disk, an I/O error), rolling back all it held, is over: each statement after it in the function is an error, and the transaction gives the database ended this transaction after an error, and rolled back all it held (since 0.7: what ran after it was committed statement by statement). An inner transaction whose error is dropped rolls the outer one back too; let _ = db.transaction(...) for the inner one says its failure is not wanted, and the outer one goes on. quillo check warns when a db.transaction(...) call's value is dropped (FEEDBACK27.md, Q-332). A SQLite path is from the program's folder, not the one Quillo runs in, and quillo run says which file it opened when it starts (quillo: database /srv/app/app.db). Only postgres://, postgresql://, mysql:// and mariadb:// are servers: any other text, sqlserver://... included, is taken for a SQLite file's path. SQL Server, Oracle and the rest are reached through Rust (see Other stores).

Many rows at once: db.exec_many(sql, rows). (since 0.7) It runs the statement for each row, a list of its ? values, and gives { changes }, the rows changed in all. It is all or none: it runs in a transaction of its own, or as a savepoint of the one it is in, so a row that fails (a duplicate key) leaves the table as it was, and gives the error. An empty list changes nothing. On PostgreSQL and MySQL an insert into t (...) values (?, ...) with nothing after its values goes as inserts of up to 1,000 rows each, as Python's executemany sends them, not a round trip a row; any other statement (an update, an insert with on conflict) runs a row at a time, in the one call. For one row's last_id, use db.exec.

let rows = for p in imported => [p.sku, p.name, p.price]let r = db.exec_many("insert into products (sku, name, price) values (?, ?, ?)", rows)if is_err(r) { reply 400 `Not imported: ${r.error}` }print(`${r.changes} products`)

A db.open that fails as the program starts, its error taken by nothing, stops it (since 0.7), with the reason: db.open: error connecting to server: Connection refused: the program's database cannot be opened, so the program does not start. That is db.open(...) alone on a line, or given back by a function called so. It ran on SQLite in memory before, answered writes, and lost them at the next start; a wrong password, a server down or a damaged file did it. An error the program takes (let r = db.open(...), ??, | is_err) is a value, as is one from a db.open later, which leaves the database as it was.

Opening another database while it runs. db.open after the start replaces the program's database. Open pages showing its rows are told at once, every tenant's (the database is the program's, whichever request opened it), and ask again; a live call is not answered from what was read in the old one.

What the calls give. db.query gives every row, all of it in memory (there is no cursor: page with limit and keyset or offset); db.one the first row or null (on PostgreSQL it fetches the whole answer and keeps one: write limit 1); db.exec { changes, last_id }. A row is a map from column names, so two columns of one name (select a.id, b.id) leave the last: name them apart with as. SQL NULL is null. The two numbers mean the same on every database. changes is the rows the statement matched: an update that sets a value a row already has counts that row (MySQL's own count, of rows changed, is ?client_found_rows=false in its URL), an upsert's or a replace's row counts once, a read and a schema statement count 0, and a script's is its last statement's. last_id is the id of the one row an insert made (on SQLite and MySQL), and null after anything else: an insert of several rows, an upsert that updated, an update. PostgreSQL has no such id, so it is always null there: write insert ... returning id with db.one, or db.query for several rows. SQL with no statement in it ("", only spaces or comments) is an error value, on all three, from db.exec, db.query and db.one alike.

One statement, or several. With values, db.exec runs one statement: on SQLite what follows the first ; is ignored, on PostgreSQL it is an error. Without values the whole text runs, statement by statement (a seed file: db.exec(read_file("seed.sql"))), and changes is the last one's. The text is split where the database would split it: a function's, procedure's or trigger's body ($$ ... $$, begin ... end), a do block and begin atomic are one statement with the rest, never a transaction begun by hand. db.exec of a statement that gives rows (insert ... returning id) runs it, on SQLite too; its rows are db.one's and db.query's.

Parameters. ? takes values in order, as many as there are ? (a different count is an error, and so are placeholders given no values, on every database), or :name takes them from one map (a name needs a value; other keys are ignored). One map is the values by name only where the SQL has names: given to SQL with none, it is no value, so a query put together from optional parts can always pass the same map; given for a lone ?, it is that value (a map stored as JSON). SQLite also reads @name, $name and #name, and numbered ones (?1, :1), taking name from the map; on MySQL @name is a variable of the session, so a map with that name is refused (`@x` is a variable of MySQL's session (null unless set), not one of the values: write `:x`). ?NNN is the NNN-th value everywhere. PostgreSQL's $1 turns the ? translation off for that statement, so $1 and ? do not mix; a ? right after a word that is not SQL's (interval ?, date ?) is taken for jsonb's operator and left alone: write ?::interval. PostgreSQL's ?|, ?& and @? are operators, ?|| is a value then ||, a :name inside brackets is a value unless it is a slice's (a[1:n]), and block comments nest, as the server reads them. At most 32,766 values in a statement on SQLite, 65,535 on the others.

A value is what it is on every database: an error value given as a parameter is not stored, but given back as the error it was (value 2 cannot be stored: got error (it failed before this call, ...)), so int("4O") never becomes a row; a function is refused. NaN and the infinities are refused, naming them (value 1 is NaN, which cannot be stored), since SQLite keeps NaN as null and MySQL refuses both; -0.0 is stored and read as 0.0, as SQLite and MySQL cannot keep it. A || joins text on all three: on MariaDB, whose || is or by default, the driver sets PIPES_AS_CONCAT for SQL that has one, migrations and scripts too.

Bytes and other types. There is no bytes value: a SQLite BLOB or a MySQL BLOB comes back as text (bytes that are not UTF-8 replaced), and PostgreSQL's bytea as \x... hex. Store base64 or hex text, or files beside the database.

What failed. db.error_kind(e) says what kind of failure a database's error value is, the same for SQLite, PostgreSQL and MySQL, whose words for it differ: "unique" (a key or unique column has the value already), "foreign_key", "not_null", "check", "conflict" (another transaction: the database busy, a deadlock, a serialization failure), "connection" (the server ended the connection, or it could not be made: 57P01 and class 08) (since 0.7), or null for any other failure and for anything else. A transaction whose first statement finds that the server ended its pooled connection while it sat idle, as after a restart or a failover, is begun again on another one: nothing had run. It needs no effect. Every database failure is an error value whose text starts db: , with the database's own message (PostgreSQL's detail in brackets, then its SQLSTATE in square brackets: db: duplicate key value violates unique constraint "seats_pkey" (Key (k)=(1) already exists.) [23505]). db.error_kind reads PostgreSQL's code first (23505, 23503, 23502, 23514, and 40001, 40P01 and 55P03 for a conflict), so it gives the same word on a server whose messages are in German. MySQL's codes are in its text already.

let r = db.exec("insert into seats (show, seat) values (?, ?)", show, seat)if db.error_kind(r) == "unique" { return err("Someone has just taken that seat") }r?

Isolation and retries. db.transaction(options, f) takes the isolation level and how many times to run f again when the database gives up on it because of another transaction:

fn withdraw(account: int, cents: int) -> map with db =    db.transaction({ isolation: "serializable", retries: 5 }, () => {        let balance = db.one("select balance from accounts where id = ?", account).balance        if balance < cents { return err("not enough money") }        db.exec("update accounts set balance = balance - ? where id = ?", cents, account)?    })

Migrations. db.migrate("migrations") (the folder is optional; that is its default) brings the schema up to date from SQL files applied in name order: 001_todos.sql, 002_tags.sql, and so on. Each file runs once, in its own transaction, and is recorded in a table _quillo_migrations with its SHA-256. A file that fails is rolled back and stops the program from starting, with the file's name and the database's message; a recorded file whose text changed later gets a warning, not a second run. Servers that start together take turns (an advisory lock on Postgres, an immediate transaction on SQLite), so each file still runs once. Only files ending .sql directly in the folder count (no subfolders); they sort by name as text, so 10_x.sql comes before 2_x.sql: pad the numbers. The name is the key: a renamed file runs again. A file holds no begin or commit of its own. The folder must exist: a missing one is an error that says why it may be gone from a clone (there is no such folder (git keeps no empty folder: put the first migration in it, or a .gitkeep, which is not read)). The table is _quillo_migrations (name, sha256, applied_at). Call it right after db.open:

db.open(env("DATABASE_URL"))db.migrate()

SQLite runs inside the server process, with foreign keys on and WAL mode, and waits up to 5 s for a lock before a write is "conflict". It is SQLite 3.46 with JSON, FTS5 full-text search and R*Tree, and without math functions (sin, acos, radians): compute those in Quillo. Values: a column declared bool or boolean gives true and false, other 0s and 1s are ints; decimal(...) and friends give decimals (see Decimals); booleans are stored as 0 and 1, lists and maps as JSON text; dates are what text you store. There is one connection per database per process, and its queries run under the interpreter's lock, on the loop's thread: a SQLite query holds up the other requests of its instance while it runs. Several processes (a program started with --instances or --split, quillo run main.qwe --instances 4; another program) may share a file: readers do not wait, writers take turns. There is no SQLCipher (no encryption at rest), and a file is opened by path, not as a file: URI (so no read-only mode). files() and quillo serve never send a SQLite file, whatever its name or folder (see Static files); file("app.db") sends one on purpose. Back it up with db.exec("vacuum into 'backup.db'") or sqlite3 app.db ".backup ...", not by copying the file while it runs.

PostgreSQL:

MySQL and MariaDB (the mysql feature, built in by default; cargo build --no-default-features leaves it out):

TLS to the database

Some hosted databases sign their certificates with a certificate authority (CA) of their own: Amazon RDS and Aurora, Google Cloud SQL (the instance's server-ca.pem), and a company's servers with its private CA. The public CAs that are trusted by default do not know those, so the connection fails with invalid peer certificate: UnknownIssuer until the URL names the CA's certificates (a PEM file, as the service publishes it):

// Amazon RDS and Aurora: their CAs' bundle, saved where the program runs, from// https://truststore.pki.rds.amazonaws.com/global/global-bundle.pemdb.open("postgres://app@mydb.abc123.eu-west-1.rds.amazonaws.com:5432/app?sslrootcert=/srv/app/global-bundle.pem")db.open("mysql://app@mydb.abc123.eu-west-1.rds.amazonaws.com:3306/app?ssl_ca=/srv/app/global-bundle.pem")// Or in $DATABASE_URL, with the password: postgres://app:...@...rds.amazonaws.com/app?sslrootcert=...

PostgreSQL reads the options libpq does:

In the URLTLSThe server's certificate
sslmode=verify-full (the default for another machine)alwayssigned by a trusted CA, for the host's name
sslmode=verify-caalwayssigned by a CA in sslrootcert, for any name
sslmode=requirealwaysnot checked, unless sslrootcert is given (then as verify-ca)
sslmode=prefer (the default on this machine)when the server offers itas with require
sslmode=disablenever

MySQL and MariaDB take the MySQL driver's options:

Several databases

db is the program's database. db.connect(target) opens another, with the same functions, and its own transactions, migrations and live updates:

db.open("app.db")let analytics = db.connect(env("ANALYTICS_URL"))           // any kind: SQLite, Postgres, MySQLanalytics.migrate("migrations/analytics")analytics.query("select day, sum(total) from sales group by day")db.transaction(() => {    db.exec("insert into orders ...")    analytics.transaction(() => analytics.exec("insert into sales ..."))   // its own})
db.open("app.db")let analytics = db.connect("analytics.db")print(analytics.sql_name("total"), db.sql_name(["day", "total"]))print(db.connect("./analytics.db") == analytics)
"total" "day", "total"true

Mounted programs and services have their own db too, so one Quillo can run programs on different databases side by side.

Read replicas. A PostgreSQL or MySQL database's replicas take its reads:

db.open(env("DATABASE_URL"), { replicas: [env("REPLICA_1"), env("REPLICA_2")] })

Shards. Data split over several databases by a key (a customer, an account):

let users = db.shards([env("SHARD_1"), env("SHARD_2"), env("SHARD_3")])users.migrate()                                           // each oneusers.on(id).exec("insert into users (id, name) values (?, ?)", id, name)users.on(id).one("select * from users where id = ?", id)   // the shard for the keyusers.all("select count(*) as n from users")               // every shard's rows, together

Joining across databases. One SQL query cannot join tables of two databases; join_on puts their rows together in the program:

let orders = db.query("select * from orders where day = ?", today)let people = accounts.query("select * from users where id = any(?)", orders | map((o) => o.user_id))join_on(orders, people, "user_id", "id")      // each order with `user`: its user, or null

join_on(rows, others, key, other_key = key, as) gives each row its first match (same value, same type) under as, which is key without _id (user), else joined; | filter((o) => o.user != null) keeps those with one.

One transaction over several databases. db.atomic keeps all of it, or none:

db.atomic([db, ledger], () => {    db.exec("update accounts set balance = balance - ? where id = ?", n, from)    ledger.exec("insert into entries (account, amount) values (?, ?)", from, -n)})

Quillo's own tables

Scheduled jobs (_quillo_jobs: each run once, whichever server it is due on) and the decisions of db.atomic (_quillo_atomic) are kept in the program's database. When there is a database per tenant they cannot be, and go to the system database instead: db.system(target) (a database every server of the program shares, for several servers), $QUILLO_SYSTEM_DB, or else a SQLite file quillo-system.db next to the program. db.system also keeps them apart from the program's own tables when there is one database.

What must commit with the work is kept with the work, in the program's database, or the tenant's when there is one per tenant, whatever db.system says: the answers of idempotent requests (_quillo_idempotency) and calls made later (_quillo_later, with the answers of those finished in _quillo_later_done). A call made with no database to go with goes to the system database, so that it outlives a restart; a program with no database at all has quillo-system.db for that (its jobs need no record: one server runs them). With a database per tenant, the system database notes which tenants have calls (_quillo_later_tenants), as nothing else says where to look.

The migrations applied (_quillo_migrations) are in the program's database. The calls a service answered (_quillo_calls) go with the jobs. quillo-system.db is made only when something must be kept in it (calls made later, db.atomic's decisions); $QUILLO_SYSTEM_DB may be a URL.

Many tenants

One program can serve many tenants (customers, teams, sites), each with its own data:

tenant_from("example.com")          // acme.example.com is tenant "acme"db.open("data/{tenant}.db")         // a SQLite file per tenantdb.migrate()                        // run for each tenant's database when it is maderoute GET /signup =                 // at www.example.com, which needs no database    <form method="post" action="/signup">        <input name="name" pattern="[a-z0-9][a-z0-9_-]*" placeholder="acme" required/>.example.com        <button>Make my site</button>    </form>route POST /signup { form: { name: str } } with db {    let made = new_tenant(name, () => db.exec("insert into settings (plan) values ('free')"))   // makes acme.db    if is_err(made) { reply 409 "That name is taken" }    redirect(`https://${name}.example.com/`)}route GET / = <h1>Welcome, {tenant()}</h1>

Sent acme, the form makes data/acme.db (with migrations/'s tables) and goes to https://acme.example.com/, which says Welcome, acme. Sent acme again, it shows That name is taken, with status 409.

HTTP client

let r = http.get("https://api.example.com/users/1")        // { status, ok, headers, body, json }http.post(url, { name: "Ada" })                            // maps and lists are sent as JSONhttp.request("PUT", url, body, { authorization: `Bearer ${token}` })

Every HTTP status is a response; failing to connect is an error value, and so is a URL that is not http:// or https://. The functions: http.get(url, headers, options), http.delete(url, headers, options), http.post(url, body, headers, options), http.put and http.patch as post, and http.request(method, url, body, headers, options). The answer is { status, ok, headers, body, json, cookies }: ok for a 2xx, header names in lower case, a header sent more than once joined with , (two Links are one </x>; rel=a, </y>; rel=b), set-cookie's lines joined by a line break, as they cannot be by a comma, and also given one by one in cookies, a list; body as text (bytes that are not UTF-8 replaced), json the body parsed, or null. A body longer than 64 MiB is an error value that says to read it with http.stream (since 0.7: it was cut there, and cut JSON was null). Interim answers (100 Continue, 103 Early Hints) are passed over to the final one. A body in gzip, deflate or br is decoded; another coding is an error value that names it (the answer is in a coding Quillo cannot read: content-encoding zstd). Requests say User-Agent: quillo/<version> unless the headers give another; some APIs (OpenStreetMap's Nominatim) refuse that one, so name your program. http.stream(url, f, opts) reads an answer as it arrives (see Streaming). Text is sent as text/plain, HTML as text/html, other values (numbers and decimals too) as JSON, unless the headers give a content-type.

Options come after the headers (in http.stream, in its map):

http.get(url, {}, { redirects: 0 })                 // a redirect is the answer, not followedhttp.post(url, body, {}, { public_only: true })     // only addresses on the public internethttp.get(`https://api.example.com/rates`, { authorization: key }, { cache: 60 })http.get(url, {}, { timeout: 5 })                   // an error value after 5 seconds
http.get("https://ledger.internal/balance", {}, { ca: "certs/internal-ca.pem" })http.post("https://api.bank.example/payments", order, {}, { cert: "certs/client.pem", key: "certs/client.key" })

Another option is an error, in quillo check when the options are written in the call, and when it runs: { timout: 5 } is not ignored.

Calls go through the proxy HTTPS_PROXY names for https:// and HTTP_PROXY for http:// (in either case, ALL_PROXY for both), unless NO_PROXY names the host (a name, .example.com or example.com for it and those under it, an address, or *). This machine (localhost, a name under .localhost, a loopback address) is never reached through one. With a proxy, the proxy looks the name up, so public_only checks the host before the call is made.

In a component, the same calls go from the page (see What the browser can reach): redirects: 0 makes a redirect an error value there, timeout is the same, and public_only and cache are the server's. A service that needs the visitor's address, called from the server, can be given request.ip.

Thread
38Useful? Like it, or add to its thread below.

Thread 0 posts

On Standard library: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.

    Edit this page on GitHub · made from the user guide · questions on Discord or r/quillo