v0.5.0
0.4.0 was the release where a second port got past the server and found the thirty-odd shapes it needed. 0.5.0 is the release where the roadmap got read back against that tree — the entries it had marked ready and waiting on a design, closed one by one — and where a sign-up form got written out beside what a zod user writes for it, which is how nilo.Text and nilo_check arrived.
One shape and seventy-nine entries. The shape is nilo.Text, with nilo_check beside it — the sign-up form, written once. The entries sit in four groups: a Row says more about its own table, and sql.Schema is the one value everything reads it from; somebody else's token is a handler argument and somebody else's API is a type; a route can say more about what it takes and what it answers; and the loop around the loop — a server restarted on every save, a queue that wakes its workers.
Needs Zig 0.16, as 0.4.0 does.
zig fetch --save git+https://github.com/nevindra/nilo?ref=v0.5.0
Eight entries ask something of you. Read this before you deploy is all of them, with the fix next to each.
nilo.Text and nilo_check — the sign-up form, written once
A u8 refuses 300 and nobody calls that a validation: the type has a shape, and the number did not fit it. Within(1, 200) said the same for a range (ADR 0206). Text had no such type, so a password's length lived in an if in the one handler that remembered to write it, and the document said string. Now it is a type, and the rule no single field can hold is a function on the struct (ADR 0264).
const SignUp = struct {
email: nilo.Email,
password: nilo.Text(.{ .min = 10, .max = 72 }),
confirm: Str,
nickname: nilo.Text(.{ .max = 30 }) = .of(""),
sku: nilo.Text(.{ .check = startsWithSku, .said = "has to be a SKU code" }),
pub fn nilo_check(self: SignUp, r: *nilo.Rules(SignUp)) void {
r.must("confirm", self.password.eql(self.confirm.view()), "has to match the password");
}
};
fn signUp(c: *nilo.Ctx, form: nilo.Bound(nilo.Form(SignUp))) !void { … }A Text is a Str that parses itself, so it is read wherever a Str is — a form field, a query value, a JSON body, a path param — and refused with one sentence in all four: "password" has to be text of 10 to 72 characters, not 7. It never quotes the text back, because a password in a 422 body and in the log line beside it is a leak; Email and Url keep the quote, because seeing the address is how the typo is found. min and max count code points, which is what the document's minLength counts, so the server and the client mean the same thing by the same number — and the document now says minLength, maxLength and format off a type that enforces them. check is any fn ([]const u8) bool, with said as its sentence; the rule a language cannot say is a function here, and a function can say anything.
nilo_check is the must ADR 0082 shipped — the same name, the same three arguments, the same sentence under the same label in the same 422 — moved from the handler onto the struct, so that every handler binding SignUp gets the rule rather than only the one that remembered it. It runs once, after every field has bound, and takes the value and nothing else on purpose: a rule that needs the database — that email is already registered — stays in the handler, where it always was. Under Bound its sentences are collected beside every field's; on a plain Form(T) it is a 422 naming every rule that did not hold. A struct with no nilo_check binds at the size it did.
Five Refusals — bounds the wrong way round, a Text with no bound and no check, a check with no said, a default outside its own shape, a nilo_check of the wrong shape — and refusals is 163. The guide is Text with a shape and Your own rules, in the same answer; what the document promises about it is What text promises. Nothing changes for a struct that names none of this.
Read this before you deploy
Five of these the compiler will find for you. The other three change what a running program or the db tool does, and those are the ones to read — sql.Schema, under the first heading, is the one every program with a database meets.
The compiler will catch these
-
.likeand.not_likeare Refusals on SQLite, naming.ilikeand.not_ilike— the rule ADR 0061 already applied to.contains,.starts_withand.ends_with, caught up with. SQLite'sLIKEfolds ASCII case and cannot be told not to by a statement, so.likethere compiled and folded, on that database only (ADR 0263).What to change: on SQLite,
.likebecomes.ilikeand.not_likebecomes.not_ilike, which is the statement that was being sent. A program that wanted case sensitivity was not getting it, and now knows. Postgres changes nothing. -
sql.Schemais the one spelling of what a program's database is, and every call that took&.{ Row, Row }takes it instead:db.checking,sql.cli.Tool,migrate.createMissing,migrate.addMissingColumns,migrate.tablesOf,migrate.missingOfandmigrate.orderOf.plan,snapshotOfandmigrations.generate/checktakemigrate.Desired, frommigrate.desiredOf(D, schema)wheretablesOfused to go. Beside.tablesthe value carries.extensions,.functionsand.views, and the tool owns their order — extensions, functions, tables by reference, each table's indexes and triggers, then views — increateMissingand in the diff, where a moved view is dropped before any table moves and remade after. A function is the wholeCREATE OR REPLACE FUNCTION <name> …statement and a view is theSELECT;@embedFileis how either gets in. The snapshot records them as a name and a hash, defaulted, so an old file reads as one with none and plans nothing (ADR 0253).What to change:
&.{ User, Org }becomes.{ .tables = &.{ User, Org } }at each of those calls — or, better, onepub const schema = sql.Schema{ … }handed to all three.comptime sql.migrate.tablesOf(D, &.{ … })handed toplanbecomescomptime sql.migrate.desiredOf(D, .{ .tables = &.{ … } }). -
Exchange.Begin.redirect_bufferisredirects, a union with a name for each intent:.refuse(the default: a 3xx with aLocationiserror.RedirectRefused),.follow = &buf, or.expose(the 3xx as itself). An empty buffer used to mean "not followed" and "not thought about" in the same spelling, and the second read as a broken server (ADR 0239).What to change:
.redirect_buffer = &bufbecomes.redirects = .{ .follow = &buf }. AnExchangethat wants a 302 handed over as itself (a signed request, a client that reads the body of a 301) says.redirects = .expose; one that left the field out and never met a 3xx changes nothing. -
jwt.Keyis akidand amaterialunion,.rsa = .{ .e, .n }or.ec = .{ .crv, .x, .y }, now that a key set can hold two kinds of key (ADR 0242).What to change:
key.nbecomeskey.material.rsa.n, and aswitchonkey.materialis how to tell the two apart. Code that only ever handed&keystoverifychanges nothing. -
Bucket.stream(c, key, &reading)takes no buffer. The one it took was documented as what the body moved through, and no byte ever crossed it: the body goes from the connection's own read buffer to the writerpipeis given (ADR 0238).What to change: drop the fourth argument and the
var transferabove it.
These change what a running program, or the db tool, does
-
app.start(io)followed bylisten()is refused when any provided service declaresnilo_start. The shape ADR 0079 recommended handed the pool one loop and the requests another: a job worker started that way crashed at boot, and a server took SIGINT and never exited.listen()now says which services took the caller'sIoand stops, witherror.StartedOnAnotherLoopfromtryListen.app.start(io)stays for a program that never listens — a test throughtesting.Client, a script, a worker onjobs.serveOn(io)(ADR 0220).What to change: move the work between
startandlisteninto a function taking*nilo.Runfirst, and register it withapp.before:try app.provide(&db); try app.before(migrate, .{&db}); try app.listen(.{ .port = 8080 });
A
sql.migrate.expect(&db, &run, manifest.head)on its own becomesdb.expecting(manifest.head)besidedb.checking, and needs no phase. -
migrations/snapshot.zonwritten before this release is read and upgraded, not refused. A foreign key holds a list of columns now rather than one, soReference.columniscolumnsandtargetistargets.std.zonfills a missing field from its default and has nothing to say about a renamed one, so nilo keeps a mirror of the older shape, tries it when the current one does not parse, and diffs against what comes back (ADR 0222, ADR 0224).What to change: nothing.
db generatesays one line noting the file is in the older shape and writes the current one out. The tables themselves are unaffected — a one-column foreign key is still written inline and byte for byte as before — so the diff against a live database is empty and the regenerated snapshot is the whole of the change. -
A version file now has a generated block rather than being one.
generatewrites abefore, anafter, aversionwhose.stepsisbefore ++ generated ++ after, and the generated list between two// nilo:generatedmarker lines. Files written by an earlier release still compile and still apply, and their hashes do not move, because the hash is taken over the steps and not over the file (ADR 0223).What to change: nothing, unless you want
--baselineto be able to rewrite version 1 in place. That needs the two marker lines around the generated steps, and the refusal says so with the line to paste.
What else is new
A Row says more about its own table
Defaults, an enum's CHECK, a named constraint, a trigger, a foreign key over several columns or to a table named as text, an index with a direction and a WHERE, sql.Date — and the tool that reads them: --baseline, a .sql twin per version, db.expecting, and a Db that was never told to check now saying so at startup.
-
A Row can say its columns' defaults,
.default = .{ .created_at = .now, .state = .draft, .seats = 1 }..nowis the one word and is refused off asql.Timestamp; everything else is a literal of the column's own Zig type, and a column with words of its own takes one of them written the way a column is (.draft, not"draft"). The snapshot carries it, so changing a default is a migration, and aNOT NULLcolumn added with one needs no backfill (ADR 0221). -
A Zig enum column writes its own
CHECK. A column read as a plain Zig enum istextwithCHECK ("state" IN ('draft', 'live', 'archived'))beside it, and the words are in the snapshot — so adding a tag is a migration rather than an insert the database refuses.db.checkingnow judges such a column at boot, but only on a table this program builds. An enum naming its own database type (pub const nilo_column = "user_role") is unchanged: its words are the database's, added withALTER TYPE.What to expect on an existing schema: the next
generatewrites oneADD CONSTRAINT … CHECKper enum column, because the snapshot did not record the words before. Applying it is the point — the rows already agree with the enum or the program was already failing on them — and a table whose data does not agree is what the failingmigrateis telling you. -
A Row can name a
CHECKand a trigger, which is the second kind of word ADR 0221 described and did not build (ADR 0226):The key is the name the object goes into the database under, because a check has no column list to derive one from and the name is the whole of what Postgres says when a row breaks it. nilo does not read the body: it writes it, hashes it, and notices when the hash moves — so a changed body is one drop and one create in the version where it changed, and a name the types no longer have is a drop.
A trigger is two halves because nilo writes
ON "<table>"between them. The table is the one thing the marker already knows, and a second copy of it is a copy that stops matching the day the table is renamed..{ .words_of = .<column> }is how an enum column's generatedCHECKgets a name of its own instead of<table>_<column>_check. Moving that name is a migration: the constraint in the database still has the old one.A
CHECKis written inside theCREATE TABLE, so SQLite takes it; changing one there is the four-statement rebuild the diff already spells out for every other table constraint. A trigger is a statement of its own and both databases do all three cases.Postgres 14 is now the floor, because
createMissingwritesCREATE OR REPLACE TRIGGERand no version of Postgres hasCREATE TRIGGER IF NOT EXISTS. Nothing else in the module needs it. -
A constraint can be named,
.name = "users_one_account_per_address"on a.unique, an.indexor a.references. Postgres reports a violation by constraint name and nothing else, so this is what makes the violation a sentence. -
Every constraint name is checked at 63 bytes on both databases. Postgres cuts a longer one down in a
NOTICEnothing reads, which left the snapshot holding a name the database did not have. Two entries that end up with one name are refused too. -
.indextakes a direction and a.where—.{ .columns = .{ .org_id, .{ .created_at = .desc } }, .where = .{ .deleted_at = null } }. The predicate is the grammar adb.selectcondition already uses, not a string:null,.{ .ne = null }, a literal,.{ .ne = lit }. A name that is not a column is a Refusal and a literal of the wrong type does not compile. -
A
.referencescan name its table as text,.{ "orgs", .id, .cascade }, for a program whose files may not import each other's Rows — a context per directory, where contexts never import each other. The type check is not given up: it runs against the Row listsql.cli.Toolanddb.checkingare given, where every Row is in one place, and a table no Row in that list claims is a compile error naming both spellings. That is what lets a context own its Row once instead of declaring every table twice (ADR 0222). -
A
.referencescan span several columns, which is how "the Epic has to be on the same board" gets said in the schema:Keyed by a label rather than a column, because a Zig field name cannot be a tuple. A composite key is written as a table constraint and a one-column key stays inline, so every table generated before this is unchanged.
.existsjoins on every column of it. -
An array column takes a default like any other,
.read_tags = &.{}or.write_capabilities = &.{ "deals", "work" }. Each element goes through the column's own element type, and the Postgres array literal is escaped so a comma, a brace, a quote, a backslash or an apostrophe inside an element does not change how many elements there are (ADR 0225). -
sql.Date— a calendar day,dateon Postgres andTEXTon SQLite, read out of the column rather than out of a::text, so adb.rawreading one needs no cast.2026-09-17in JSON,format: datein the document. A day is not a moment: a due date read into atimestamptzgets a midnight, and a midnight has a zone. A day before 1970 is ordinary, which is wheresql.Timestampstops. -
An enum column's values are checked at startup.
db.checkSchemaused to judge an enum field carryingpub const nilo_column = "user_role"by the column's type name and stop there, so a Zig enum that had fallen behind its Postgres type was found by the first request that read such a row. It now askspg_enumfor the type's labels and reports each one the Zig enum lacks (value_zig_lacks) and each tag the type lacks (value_type_lacks), the same way a missing column is reported. SQLite has no enum type and skips the check; a Dialect says whether it can withenum_values, and a Wire answers it withlabelsOf. -
A
Dbthat starts withcheckingnever called says so, once, atwarn: the Rows will be checked by the first request that reads them..unchecked = trueinOptsis how a program says it meant it — a word rather than an empty list, because an empty list is a claim to have checked. Acheckinglist given still runs whatever the option says (ADR 0262). -
db.expecting(version)— refuse to serve a database whose migration ledger is behind the binary, checked at boot on the poollisten()just opened. A call rather than an option onDb.Opts, because the option measured 17,296 bytes in every program with aDbin it and the call measures 16. -
db generate --baseline— forget the snapshot, diff the Rows against nothing and rewrite version 1 where it stands, keeping everything outside its generated block. What porting a schema needs, where the loop is one version derived over and over. Refused once there is a version 2, when--namedisagrees with the version 1 on disk, or when the file has no generated block; each message names the files and nothing is written. -
db generatewrites a.sqltwin beside every version file, anddb checkfails when one no longer says what the version beside it says (ADR 0227):The same statements in the same order, wrapped in
BEGIN/COMMIT, with the ledger table created if it is not there and the ledger row on the end — sopsql -f, a CI job with no toolchain or somebody on a jump host can bring a database to head, anddb.expecting(manifest.head)still serves it andverifystill holds the hash. It is an output: nilo reads the.zigand never this.One case writes nothing and says so.
--baselinerewriting a version 1 that has hand-written steps in it produces a file whosebeforeandafterare Zig nothing has compiled yet, so the twin's hash cannot be worked out. Build, then rundb checkor anydb generate. -
sql.Db,sql.Named,sql.Sqliteandsql.SqliteNamedsay their own name in a nilo message, rather thandb.DbOf(postgres.Wire,…). -
sql.migrate.addMissingColumns(db, run, &.{ Rows… })— the step betweencreateMissingandapply: oneALTER TABLE … ADD COLUMNper field a shipped table has not got, from the sameDescthe create reads, in one transaction. For the single-file program that added a field and wants neither a ledger nor a version for it. A required column with no default iserror.NeedsBackfillwith the statement in the log, and nothing is sent.db.liveColumnsis the introspection it reads, made public (ADR 0233). -
db.rawanddb.rawOnetake a column type.db.raw([]const u8, run, "SELECT name FROM pragma_table_info('downloads')", .{})reads column one of every row with no Row and no marker;i64,?bool, aStrthe same. ASELECTlist of two into a scalar is refused while compiling, the way a short list into a Row is (ADR 0234). -
sql.on(D).selectFor(Row, Options)— and the fourteen beside it — spell a statement for the Dialect you name.sql.selectForand its siblings hard-code Postgres, so a program on SQLite could not ask what SQL its own query compiles to.sql.on(sql.SQLite)binds them all to that Dialect;sql.SQLiteis exported besidesql.Postgresfor it. The old spellings are unchanged. -
A connection URL is read the way libpq reads it, and a parameter the driver would not act on is refused by name.
dialOptsused to understandsslmodeandtcp_user_timeoutand stop the server on anything else, so the URL a hosted Postgres hands out —application_name,connect_timeout,pgbouncer=true,sslrootcert— was a server that would not start. Every libpq parameter now goes one of three ways. Carried onto the field pg.zig has for it:user,password,dbname,hostandportas query forms (checked against the part before the?),sslmodedisable/require/verify-full,sslrootcertbesideverify-full(systemfor the platform's store),application_nameandfallback_application_name,connect_timeoutin seconds,tcp_user_timeout, and the fourkeepalivessettings. Dropped with onewarnline naming them, because the driver does it already or nothing observable changes:pgbouncer,pool_mode,sslsni=1,gssencmode=disable|prefer,channel_binding=prefer|disable,target_session_attrs=any. Refused with a line naming the parameter, the reason and the list understood:sslmode=prefer|allow(would fall back to plaintext),verify-ca(checks half of whatverify-fulldoes),sslcert/sslkey,options,channel_binding=require,gssencmode=require, any othertarget_session_attrs,sslsni=0,client_encoding, and anything unknown. A query string is split before it is percent-decoded, so apassword=holding&survives. Two new error names,UnsupportedConnectionParamValueandConflictingConnectionParam, both counted as URL problems bynilo_start.
Somebody else's token, somebody else's API, and the bucket
nilo.Verified(V) puts the claims behind a bearer token in a handler's argument list; jwt.Keyring holds the key set and rotates it under its readers; ES256 joins RS256. fetch.Target names a service once, and the rest fills in what the first release of nilo_fetch left out. nilo_s3 lists a page.
nilo.Verified(V): the claims behind a bearer token as a handler argument, or a 401 withWWW-Authenticate: Bearerand the reason before the handler runs.Vis ajwt.Verifier(Claims, Client)— new, the ring, the client its refresh needs and the claims type held as one service,provided once and looked up by the argument..claimsis parsed into the request arena;.tokenis the token as sent;T.refuseis the 401 after reading;c.verified(V)is the same read for a middleware. The issuer's keys unreachable when a refresh was needed is a 503 rather than a 401, since the token was never judged. The document carries the bearer scheme.nilo_httpreads a marker and imports nonilo_jwt;nilo_jwttakes the client as a type and imports nothing. Three refusals;refusalsis 158 (ADR 0260).jwt.Keyring: a key set that rotates under its readers.init(gpa, .{ .url, .issuer, .audience })holds the issuer's URL and what every verify insists on;refresh(scope, client, now_s)fetches the document through thefetch.Clientyou pass — the module still imports nothing — andload(bytes)swaps it in, freeing the old set only after the verifies already reading it are done, with no wait on the reading side.verifyOrRefresh(Claims, gpa, token, now_s, scope, client)isverify, and onNoSuchKeyone fetch at most perrefresh_interval_s(60), thenverifyagain. The guide's three-line rotation — refetch, hold a*const Keys, swap under a mutex — is gone, because each line was wrong in a way no test finds (ADR 0255).nilo_jwtreads ES256. A JWKS key that says"kty":"EC"onP-256is checked as ECDSA over P-256 with SHA-256 — what Supabase and Apple sign with — through the sameverify, and the token's header still picks nothing: the key's type decides the algorithm, the header'salgis compared against the two names before a key is looked up and against the key's own name after, soES256over an RSA key andRS256over an EC key are botherror.WrongAlgorithmbefore any arithmetic. A curve with no branch iserror.CurveNotSupportedby name; a DER-shaped signature, which is the mistake every signer outside JOSE makes, iserror.SignatureWrongLengthrather than aBadSignature.jwt.curveslists the one curve, besidejwt.key_sizes. The vector is RFC 7515 Appendix A.3, verbatim (ADR 0242).fetch.Target(name, .{…}): a service's base URL, standing headers and ceilings as a type of its own, opened once on the client and asked for by type —fn charge(stripe: *Stripe, c: *nilo.Ctx). Every call the client has, with a path in place of the URL:stripe.get(c, "/v1/charges/{}", .{id}, .{}), the segments counted while compiling and percent-encoded with/as data; name them,{id}, and the same struct is the query, every field the template does not name going on the end underwithQuery's rules.authorizationanduser_agentgo through std's slot and a call's own line goes instead of either;max_in_flighton the type is this service's own gate, taken before the client's, so a slow third party stops eating the permits every other one shares;readyis a path the health route GETs. The base and the credential are given toopen, where aConfigcan reach them, because a sandbox host in development is the same binary. Twelve refusals;refusals-fetchis fifteen.examples/outboundis aGitHubtarget now, and the five lines it wrote to encode two path segments are gone (ADR 0254).client.postJson(c, url, value, .{}), withputJson,patchJsonandsendJson(c, method, url, value, .{}): the value written out withstd.jsoninto the Scope and sent undercontent-type: application/json, unlessheadersnames one. Whatres.json(T, c)is for the way in. Text handed to any of them is a Refusal — it would go out as one JSON string — and a body already encoded goes throughpost(ADR 0243).fetch.withQuery(c, base, .{ .page = 2, .q = "a b" })isbase?page=2&q=a%20bin the Scope, one allocation sized exactly. A field is an int, a bool, text or an optional of one, where null is the param left out; any other type is a Refusal naming the field. A base that already has a?gets&. The path half — a segment encoded on the way in — waits on a target to hang it off (ADR 0243).res.header(name)andres.headersonfetch.Response: the header block the answer arrived with, kept into the Scope before the body read over it, soRetry-Afteroff a 429 orETagfor the next conditional GET is one call away aftergetreturns. A whole-body call now makes two arena allocations rather than one — the block, then the body — and the test that held the one now holds the two (ADR 0244).stall_ms, onfetch.Client.Settings,CallandExchange.Begin: the call iserror.Stalledwhen nothing has arrived for that long, counted from the last byte rather than from the start. The other shape of bound, for the call whose whole point is the transfer and whose only honesttimeout_msis0: a peer that goes quiet and holds the socket now ends, and a slow one that keeps moving never fires it. Under a server it is the Engine's timer re-armed on every chunk; on a client with no Engine it is ADR 0230's cancelled task, with the wait re-read from the last byte.Exchange.stream(w, limit)is one chunk of the body inside both clocks, for a body moved in pieces of the caller's own choosing, and its zero is the end of the body and nothing else: std's TLS reader answers zero for a record that carried no application data (a session ticket, a record decrypted into its own buffer), andstreamreads on past those, which fdm's first run against it over TLS found as every segment ending short (ADR 0237).head.keep(c)is the sameHeadcopied into the Scope, so it reads the same after the body has been through: theetagtaken before a download and compared against after it, without a[512]u8of the caller's own (ADR 0240).fetch.Client.Settings.read_buffer_size, std's 8 KiB passed through: the buffer each connection reads the socket through, and so the number that decides how much one read brings in.Begin.transfer_buffernever did, and its comment now says what it is for: a caller reading buffered offex.reader, and nothing else.Client.sendno longer declares 4 KiB of it (ADR 0238).Exchange.Begintakes auser_agent, besidehost,authorizationandcontent_type: the fourth headerstd.http.Clientwrites for itself. AUser-Agentput inheaderswent out twice — std'szig/0.16.0 (std.http)and then the caller's — which is what a download manager sending the header a browser's "Copy as cURL" carries found the first time a host looked at it.fetch.Settings.timeout_msfires without an Engine. On a client started withnilo_start(io, .none)— a CLI, a worker, a test — a non-zero timeout used to arm nothing and say nothing, and a server that stopped sending was held forever. Each step of the call now runs as a task of thatIoand is cancelled when the clock runs out, soerror.TimedOutmeans the same thing at either end. One thread hop per step, only on a client with no Engine and a non-zero timeout; underlisten()nothing changes (ADR 0230).- A header std has a slot for is sent once, and it is the caller's.
host,authorization,user-agent,content-type,connectionandaccept-encodinginBegin.headersorCall.headersused to go out beside std's own copy. A caller taking headers off a pastedcurlline no longer routes them into fields by hand; the six names are known infetch.zigand nowhere else (ADR 0231). head.redirectedandhead.location(&buf)say where a followed redirect ended, so the connections after a probe go to the final URL rather than walking the chain again. The text lives in theredirect_bufferthe call was given (ADR 0232).ex.discard()— "I will not read this body; close the connection" — for the probe that asked for one byte and got the whole file.max_drainstays a policy for every call rather than a lever pulled for one (ADR 0235).fetch.testing.Canned— the loopback server the module's own tests drive, exported for a suite of your own:open(io),reply(status, headers, body),serveOne,url(&buf),request(),requestBody(). Port 0 and the kernel's answer read back, so it needs no port range. The guide's testing section used to say "copy the shape offetch/live.zig'sCanned"; it now shows the call (ADR 0243).zig build refusals-fetch,nilo_fetch's first Refusals table: three rows, run bytest-fetchand so bytestandtest-all.nilo.Limits.noneis the name for "no Engine underneath"..offread as "start with something off" and the first guess at what was logging; it stays as the same value, so nothing already written breaks.bucket.list(c, .{ .prefix, .max_keys, .cursor })innilo_s3: one page of a bucket's keys under a prefix, asPage—objects, each aListedwithkey,size,etagandlast_modified, andnext, the cursor for the page after or null. At most 1,000 a page, which is S3's own ceiling and is refused rather than clamped; the body is bounded by what that many keys can weigh; and nothing follows the cursor for the caller. The XML is five element names scanned for, the waycode.zigreads an error body, and the request asks forencoding-type=urlso a key with an&in it is a percent problem rather than an XML one (ADR 0250).
A route can say more about what it takes and what it answers
Text with a shape and a rule on the struct; a 304 a handler names; an answer kept a minute; a tree the binary carries.
nilo.Text,nilo.Email,nilo.Urlandnilo_check— above (ADR 0264).- A
Form(T)field can be a list.tags: []const Str = &.{}takes every value sent undertags— a checkbox group, a<select multiple>, a row of inputs sharing a name — in the order the browser sent them, each converted the way a single field is, so[]const Kindfor an enum refuses a bad value with the sentence a singlekindgets. Nothing sent is the empty list, never a 400; an empty value contributes nothing; and a comma is data, because a browser never joins a group with one, so there is no second spelling to read as there is for a query list. UnderBound(Form(T))the first bad value is reported and the rest are still read. One arena allocation on a form that asked for a list and no other; a list ofUploadis a Refusal (ADR 0256). nilo.Versioned(T):Twith au64version the handler names, sent under a weakETag—W/"1a"— and answered 304 with no body whenIf-None-Matchcarries it.c.clientHas(version)asks first, so a handler returning.unchanged(version)skips the query as well as the bytes; one that never asks still answers 304.headersgo out on both answers.Versioned(?T),Versioned(void), one inside aStatusor aResponse, and one under aCachedor anIdempotentare Refusals, andrefusalsis 146; the document puts theETagon the 200 and a304beside it (ADR 0258).nilo.Cached(Pages, .{ .ttl_s = 60 })as a route argument: the answer a GET or HEAD returned is kept under the path and query in a bytes Space and served again, byte for byte, forttl_s— the handler does not run. A request that finds the answer still being made waits for it rather than running the handler again, which is the stampede answernilo_cachecannot give on its own..bypicks the key (.path_and_query,.path, or one header); a credential as the key is a Refusal, and so is a POST. Costs whatIdempotentcosts, on the route that asks and nowhere else (ADR 0247).app.guard(middleware, cookie): the API description says which routes are behind a session cookie. Every route the middleware is in front of — throughuse,useOnorwith, less whatwithouttook out — is written with acookieAuthrequirement and a 401, andsecuritySchemesgains{"type":"apiKey","in":"cookie","name":…}. Which routes is read from the middleware wiring when the document is written, so an exception moves in the document the moment it moves in the program; the cookie's name is the one thing taken on your word. One per App, and declaring it installs nothing (ADR 0252).app.embedded(prefix, files)andembeddedWith: a tree the binary carries, served the way a directory is. A list of.{ .path, .bytes }with@embedFileon each goes through the same Setapp.staticbuilds — an ETag per file, a gzipped copy made once, the SPA fallback, nothing per request — with the bytes borrowed from the binary rather than read or copied. The options arestatic's less every one that is about a disk. A path listed twice and a fallback that names no entry are refused at startup, in one line each (ADR 0249).- A request body under
Content-Encoding: gzipis inflated before anything reads it —c.body(),c.json, a struct argument, aForm(T)— where 0.4.0 answered every coding butidentitywith a 415. The stock OpenTelemetry Collector and most agents gzip by default. No window and no pool:std.compress.flate.Decompressuses the destination as its history, so the arena buffer that holds the body is the window, and the gzip trailer's length makes that one allocation exact and themax_bodycheck a comparison before a byte is inflated. A stream that does not decode is a 400 naming the coding;br,deflate,zstdand stacked codings are still a 415, and its message now says which one is decoded.c.bodyStream()does not decode and answers a gzipped body with a 415 that says so (ADR 0251). space.incr(key, delta)on acache.Spacewhose value is an integer: the read, the add and the write under the shard's lock, answering the new count, so two requests arriving at once count two where agetand aputcounted one. A key nobody wrote counts from zero and livesttl_s; one there keeps the expiry it had, so a window does not slide with the attempts inside it. Saturating.incron a Space of anything else is a Refusal naming the type;refusals-cacheis six. ADR 0138's rule reads "nothing that waits", and an add is not a wait (ADR 0261).app.boundPort()says which port the server is listening on.?u16, null untillistenhas bound and null for a unix socket. The Engine reads the address back afterlisten, so.port = 0is now something a test can ask for:http/live.zigdoes, and nothing in the suite walks a range of loopback ports any more.
The loop around the loop
The server restarted on every save, work that runs before the first request, a queue that wakes its workers, and the secret that is not a password.
nilo-dev, andzig build dev-<example>: the server restarted on every save. Onezig build --watchkept running, and the server started again whenever the binary it writes changes — a build that fails prints its errors and leaves the old server serving. Ships asnilo.artifact("nilo-dev"), three lines in a dependent'sbuild.zig; importsstdand nothing of nilo's, so no server links it. A save writes the whole binary into.zig-cacheand Zig keeps every one, so after each restart the runner deletes the directories holding earlier builds of the binary it serves — four saves left the cache 0.0 MB larger — and--keep-cacheleaves them.--incrementalkeeps the compiler resident instead and, on Zig 0.16.0, needsexe.use_llvm = truebeside it — the self-hosted backend's incremental binary does not run when libc is linked (ADR 0259).app.before(f, args)— work that needs the services and has to finish before the first request. Runs once insidelisten(), after the services have started and before whatspawnregistered, on the server's loop, with anilo.Runmade there. If it fails, the server does not start. Three shapes are refused while compiling: a value rather than a function, a first parameter that is not*nilo.Run, a function answering with a value.- A
pushwakes a worker.jobs.pushfrom the process the workers run in used to wait outpoll_ms— half a second on average at the default — so the first CLI set it to 100 and paid sixteen workers × ten idle claims a second against one SQLite file. A push now wakes one sleeping worker through theIo's futex, andpoll_msis what finds a row another process pushed.jobs.wake()is for that row, or onepushInput under a transaction that has since committed (ADR 0229). - A job can push the next one.
.depson ajob.Jobsmay be a function of the queue type,fn (comptime Jobs: type) type, answering the struct of pointers a plain.depsis; arunthen asks forjobs: *Jobsand pushes the next kind..deps = struct { jobs: *Jobs }was adependency loopin the compiler's words, and so was arunnaming*Jobs— every check that reads arun's signature now waits for the queue type when.depsis a function, so for such a queue arunwith the wrong shape is reported at the firstopenrather than at thejob.Jobs(…)line.Jobs.Depsnames the struct either way; a plain-struct.depsis unchanged. Two Refusals: a.depsfunction of another shape, and one whose struct lacks what arunasks for (ADR 0245). jobs.cancel(c, id): aqueuedrow taken back before it runs —truewhen it went,falsewhen a worker already holds it, it finished, or there is no such row. OneDELETE … WHERE state = 'queued', so a claim in the same instant wins or loses whole; theuniquekey goes with the row, so a cancel and a push is "move it to tomorrow". Onjob.Memoryandjob.Tableboth; a store of your own that cannot take a row back is a Refusal at the call (ADR 0257).- A
runmay ask fortick: job.Tickbeside its deps — the row'sid,attempts,run_at, andlastfor whether this is the attemptretrystops at — by value, since after the job and the Run a pointer is a service.*job.Tickis a Refusal naming the rule. On top of it,jobs.progress(tick.id, n)writes a figure into thestatusSpace andjob.Statuscarries it asprogress, reset by every change of state exceptdone(ADR 0246). jobs.drainAt(&run, now)andjobs.runOneAt(&run, now)run what would be due if it werenow, and every read of the clock inside the tick — the retry's wait, the schedule's next tick, a missed tick — reads that number, so a test walks a backoff or a cron to three in the morning without sleeping.jobs.seed(&run)/seedAt(&run, now)queue every schedule's first tick, which is whatservedoes at start.drainandrunOneare the same calls atnilo.nowMicros(), anddrainnow reads the clock once for the whole run rather than once per row (ADR 0246).pw.Token— the secret that is not a password: a reset link, an email verification, an API key.pw.Token.new(try c.entropy(pw.token_len)), thentoken.text()to send (43 characters of base64url) andtoken.digest()to store (SHA-256);pw.Token.matches(stored, presented)decodes, hashes and compares in constant time, and answersfalsefor every wrong shape rather than an error that says which. No argon2: a 256-bit token needs no stretching, and a reset endpoint that took 13 ms to say no would be one that can be walked.pw.Token.parseis the token read back, for the lookup where the digest is the key. A[16]u8— a UUID's bytes — is refused in nilo's words (ADR 0241).nilo.verifyPassword(gpa, stored, text)andnilo.verifyPasswordWith:c.verifyPasswordwith no request in hand, through the same Gate and the same blocking pool, for a CLI resetting an account, a job re-hashing at a raised Cost, or a test with neither an App nor aCtx. Checking reads the salt out of the stored string and never needed the request; the method keeps its signature and calls this (ADR 0241).nilo_corereads the clock on Windows.nowMicrosandmonotonicMicroswere a@compileErrorthere; a program onnilo_fetch,nilo_sqlandnilo_jobwith no Engine now cross-compiles forx86_64-windows(ADR 0228).- The reference is a folder.
docs/reference.mdwas 4,154 lines, and is nowdocs/reference/: one page a module, seven for the server cut where the guide cuts, and every heading listed once on itsREADME.md. A link into the old page names the new one under the same anchor, sodocs/reference.md#runisdocs/reference/core.md#run(ADR 0236). zig build snippetsrefuses a documentation page that carries a<!-- compiles -->mark and is not on its list. It walksREADME.mdanddocs/for the mark, so a mark cannot be written anywhere the step will not read it.docs/guide/openapi.mdwas the page that had one and was not listed; it is now.
Fixed
jwt.verify's claims andjwt.parseKeys'skidno longer point at memory that is gone.std.json's default for a slice input hands back a slice into the input for any string with no escapes, soclaims.subpointed into the scratch arenaverifyfrees on the way out, and a key'skidpointed into the response body it was parsed from — while both doc comments promised the opposite. Unnoticed because every caller so far handedverifyan arena, and a scratch arena freed into an arena gives nothing up. Both parses copy now, and a test frees the claims one string at a time on the debug allocator (ADR 0242).- A call the Engine's own deadline stopped no longer drains the body it stopped waiting for.
endskipped the drain when the engineless clock had fired and not when the Engine's had, so a body that stalled after its head underlisten()was given up on and then read to keep the connection, which on a server that went quiet is the read that never returns. Found by the first stall test under the Engine, at zero CPU (ADR 0237). - A Row over a view in an attached SQLite database is introspected as a view.
columnsOfrewrotepragma_table_infoto the attached schema and leftsqlite_masterbeside it pointing atmain, so the view's columns came back all-nullable — the failure ADR 0056 was written to remove. Both relations are qualified now. - A Row column of a type no Dialect can decode is a compile error naming the field. A plain struct of your own in a Row used to pass the startup check and stop on the first read inside pg.zig, as
cannot decode value of type …User__struct_3276.db.selectandStreamednow refuse it while compiling, listing what a column may be and the three ways to make the field readable (sql.Json(T),sql.AsText("…"),nilo_beside). Nothing that compiled before stops compiling. One refusal. .ilikeon SQLite is spelledLIKE. It was writtenILIKEon both Dialects and came back a syntax error from SQLite, whoseLIKEalready folds ASCII case — so it is the same one-word swapicontainsmakes there (ADR 0061)..not_ilikelikewise. Nothing could have depended on the old spelling.- A batch on SQLite is refused in one sentence naming the dialect. The Refusal used to fire from the per-column branch, call itself "a batch insert" from
updateManyas well, and blame the column — the sqlite dialect has no column type fori64— which was false and sent the reader todialect.acceptsto find out. It now says the database has no array parameter and what to do instead, with the caller's own verb. Two refusals. - A
Strparsed out of a job's payload goes stale when the tick does.Str.jsonParseanswersstatic, so a payloadStrheld past its tick used to pass the Debug trap that catches the same mistake in a handler.nilo_jobstamps the parsed payload through the Run now, the wayCtx.jsonstamps a body.core.stampWith(&value, scope)is the call, besidestamp. fetch/live.zigands3/canned.zigbind port 0. Both walked a thousand loopback ports from a thread-derived start, held apart from each other by comment, because this repository believedstd.Io.net.Servercould not report the port it was given. It could all along:Server.socket.addresscarries it afterlisten.http/live.zigbinds port 0 too, throughapp.boundPort()above.
Docs
- The roadmap is what is still open, and nothing else. It is now five sections by what an entry is waiting for —
Next,Known, waiting for a caller,Open questions,Measurements outstanding(one table) andWaiting on upstream(one table, with the pin each row was last checked at) — grouped by module inside the first three, in place of a section per module withNext,Known gapsandNot decidedunder each. What was accepted as the rule, what was answered in a line and what is not coming moved todocs/decided.md, and the three standing risks with no mechanism under them yet moved todocs/risks.mdbeside the ones that are held. Links into the old per-module anchors now point at the section instead. - The keyset form of a deep page.
db.page'sOFFSETgets slower as a list goes deeper; Reading now shows the(created_at, id) < (…)condition written as.any, with the order term that keeps NULLs in a stable place beside it. cache.Spaceof bytes, with a struct behind it. The guide's own Refusal for a value that holds a pointer pointed at a sentence and no code; it now showsstd.json.Stringifyinto a bytes Space atputandstd.json.parseFromSliceLeakyoff the request arena atget.- A
<!-- compiles -->block that only declares a type is now a documented convention rather than a silent gap.docs/snippets/README.mdsays why a block declaring a Row and nothing else needs acomptime { _ = … }naming it, and the guide's own Row examples that were missing one now carry it.
Where to read next
Every module has a page under docs/guide/, and the whole public API is one page a module under docs/reference/. What is still open is docs/roadmap.md, now five sections by what each entry is waiting for; what was decided and is not coming back is docs/decided.md.
The full list, every entry with its ADR, is in CHANGELOG.md at v0.5.0. What was measured and what turned out false on the way is in docs/history.md.