Repository navigation
v0.7.0: wrong answers closed, and what a port needed
0.7.0 fixes a long list of places where nilo returned a wrong result without raising an error, and adds what was missing when a real log server was ported onto it. Most changes are of the first kind: something that used to succeed quietly with the wrong result now fails with a clear error, or does the right thing. Several of them change how a running app behaves without the compiler saying a word, so read the section below before you deploy.
Needs Zig 0.16, as 0.6.0 does.
zig fetch --save 'git+https://github.com/nevindra/nilo?ref=v0.7.0#e1b859f8230a4cffd09d8e84411f7bcd7524258a'
Keep the #commit: the tag is annotated, and Zig 0.16's zig fetch does not peel it, so ?ref=v0.7.0 alone is whatever main is that day.
Read this before you deploy
Upgrading takes three passes. First, the changes that compile and then behave differently, below, because nothing will point at them for you. Then build, and let the compiler walk you through the rest. Then, if you wrote your own store, Wire, Dialect or Space, the last section.
1. Changes nothing will warn you about
If your app has sessions. The cookie is now __Host-session, and the old session cookie is no longer read by default (a sibling subdomain could plant one). Without a change, every signed-in user is signed out on deploy.
- Pass
listen(.{ .session_plain_name = true })for as long as your longest sessionmax_age, so the old cookie is still read while each visitor's nextsetmoves them to the new name. app.guard(…, "session")becomesapp.guard(…, nilo.session.host_cookie_name), and a test or client that reads the cookie by name reads the new one.- A session with a
domain, anotherpathorsecure = falsecannot use the prefix: it keeps the plain name and needs the same option, or itssetfails naming it.
If you connect to Postgres on another machine. A URL to a host that is not this machine, with no sslmode, now requires TLS, and a server that offers none is refused at startup. Add ?sslmode=disable if you mean to talk to it in the clear. localhost, 127.0.0.0/8, ::1 and a unix socket are unchanged; a Docker Compose service name such as db counts as another machine. tcp_user_timeout in the URL is now refused; use connect_timeout.
If you store moments in a Postgres timestamp column. sql.Timestamp now reads only timestamptz, and the startup check refuses a timestamp column, so the server will not start until it is converted. The refusal prints the statement with your names filled in: ALTER TABLE t ALTER COLUMN at TYPE timestamptz USING at AT TIME ZONE 'UTC'.
If you use SQLite migrations. Run db generate once after upgrading. Every existing .sql twin is reported stale by db check until you do, because a twin now stops its shell at the first failed step and carries the foreign-key handling below. A version that drops a table now runs with foreign keys off and checked before COMMIT; every other version keeps them on, so a DELETE of a parent cascades as your schema says.
If a Postgres migration may wait on a busy table. A step now gives up after five seconds waiting for its lock, with error.Locked and nothing kept, instead of stalling every read and write behind it. A version that must wait longer sets .lock_timeout_ms on its Version (0 waits for good). Setting it on a version that already ran is not drift.
If you have a case-folding unique on Postgres and search it with .istarts_with. The prefix search is now lower(col) LIKE … over a text_pattern_ops index, 266 ms to 0.065 ms on 200,000 rows, but only once the index is rebuilt: an existing one keeps scanning until you drop it and let it be made again.
If clients read your JSON as text, or pin exact bytes in tests.
- A float is written the way serde_json writes it:
1.0where it was1,1e+16where it was seventeen digits, anf32from its own digits (1.1, not1.100000023841858). A test holding"ratio":0now sees"ratio":0.0. - A
sql.Timestampalways carries six fractional digits (2026-08-16T09:30:00.700000Z), and aTimestamporDateoutside 0001 to 9999 is an error rather thannull. Any RFC 3339 reader takes both.
If your SPA fallback answers API typos today. spa_fallback_for: .navigations now answers only a real navigation (Sec-Fetch-Mode: navigate, or an Accept naming text/html). fetch('/api/nope') and curl /users/42 get the 404 they should have got. Delete any catch-all GET /api/* route you wrote to stop the page answering, and send Accept: text/html from a check that expects the page.
If a middleware can stop the chain. A middleware that returns without answering and without calling next.run(c) is now a 500 with the middleware named in the log, where it was an empty 200 with the handler never run. A guard that forgot its 401 let the request through as a success; make it answer, with a fail function or c.send.
If a handler catches a failed statement and carries on in a transaction. tx.commit() after a statement failed now answers error.QueryFailed and rolls back, on both databases; on Postgres it used to report success having kept nothing. Wrap the statement you mean to survive in a savepoint (var sp = try tx.savepoint();, sp.rollback() in the catch), or use insertOrIgnore when the failure you expect is a duplicate. Separately, a serialization failure or deadlock is now error.RolledBack (run the whole transaction again), RolledBack or Disconnected escaping a handler answers 503, not 500, and on Postgres a full pool that outwaits timeout_ms is error.TimedOut where it was Disconnected (timeout_ms = 0 now means no bound, where it failed at once).
If you list rows with a .limit or .offset. A cut order now ends in the table's key, running the way your last term runs, so rows that tie on the order come back in the same order on every page. An .order over a Decimal, Interval or Inet column sorts by value on Postgres, not by its text. A grouped Row with a parent or a .through field groups by the referenced row's key too, so two customers named Acme are two rows. Pages are now correct; a test that pinned the old order or the old SQL text will move.
If you use nilo_job. A row pushed with pushIn has no status until a worker takes it, so a route polling status(id) right after reads null as not started yet. jobs.retryDead on a dead row of a scheduled kind answers error.Scheduled and changes nothing; a stopped schedule comes back through the re-seed. In a cron schedule, a day field starting with * (*/2 included) now makes the day match both day fields, as Vixie cron does, where only a bare * did: a schedule naming both a day of the month and a weekday may now run on different days.
If you use nilo_cache TTLs in tests. An entry lives at least its TTL and at most one second more. A test that waits exactly ttl_s for a miss waits ttl_s + 1.
If you stream to or from S3. stream and putStream are no longer cut at the Store's timeout_ms: they stop after stall_ms (30 s) of silence, and a call that wants a ceiling sets .timeout_ms itself. They also share max_streams slots (half of max_in_flight by default), so a program holding more streams than that at once now waits for a slot: raise max_streams.
If you test with testing.Client or testing.Conversation. An answer larger than response_bytes (64 KiB) is now error.ResponseTooLarge instead of a truncated 200. Raise .response_bytes for a test that fetches something large.
What used to work wrongly and now refuses at run time. Each answered with a plausible result before; each is now an error you may see in logs:
- an
updateordeletewhose condition matches every row (an empty.not_in,.contains = "", a.likeof only%), and a negative.limitor.offset, refused before they are sent. A call that really means every row usesdb.raw. insertOrIgnoreandinsertOrUpdatewith a conflict target the values do not carry, andupdateReturningOnewith a.wherethat could match more than one row.- on SQLite, a number read out of text or a REAL, and on both, a raw statement whose columns do not fit its Row, which now fails the statement in a test binary the first time it runs.
migrate.applyPendingwhen a version it already ran was edited (error.SchemaDrift), a column type that does not widen, which now has to be named with--drop table.column, and on SQLite a column added with.default = .nowto a table that exists, now a Problem in the plan.rawPagewith anOFFSETnilo cannot set back to 0: writeOFFSET $nand work the number out in Zig.- on SQLite
:memory:, adb.rawthat writes but begins like a read, since readers are openedquery_only. cache.Space.openfor a flat value no shard could hold, which now panics naming the type and the limit, where everyputwas refused without a word.- an S3 call with an empty key or a header value holding a control byte (
error.Rejected), configuration that could never sign (refused bys3.open), and a listing page marked truncated with no usable token (error.Failed). - a request the parser used to pass on:
Transfer-Encoding: gzip, chunked(501), a target not starting with/, aHostthat is not an authority, a urlencoded form of more than 1,024 pairs, a JSON body repeating a key, JSON numbers outside the field's grammar (5.0for an integer,"1_0"), and anIdempotency-Keytoo long for the route's Space, all 400;HTTP/2.0on the HTTP/1.1 port is a 505.
2. Build, and let the compiler find the rest
These no longer compile, each with a message naming the fix:
- Error sets that grew or changed, for an exhaustive
switch:Ctx.sendand the stream and upgrade calls gainAlreadyAnswered; aBodyread gainsBodyTruncated;url.intogainsBadValue;Room.ErrorgainsWriteFailedandEventFieldBreaksLineand losesAlreadySeated(a socket may now sit in several rooms, andSocket.ticket(),inRoom(),seatedIn()andunseat()are replaced byseating()andleaveRooms());Space.incrandStore.addcan fail withTooLarge;Jobs.ErrorgainsScheduledandEmptyUniqueKey;cache.OpenErrorgainsSeedUnavailable;s3.OpenErrorgainsBadCredentials,BadRegion,BadEndpointandBadStreams;wire.ErrorgainsRolledBack;migrations.ErrorgainsSnapshotBehind, andmigrate.Kindgains.rename_indexand.rename_constraint; SQLite'serror.PrivateMemoryDatabaseis gone, an empty URL beingerror.EmptyDatabaseUrl. - Signatures:
nilo.maxBodyreturns anmw.Limited(a variable typedMiddlewareuses its.run);c.peer()returns*const Peer;static.Set.fallbackForandstatic.navigationaltakestatic.Asked;generate --dropnames what it drops andmigrations.Options.allow_destructiveis.drop. - Schema and query shapes nilo now refuses while compiling: an upsert conflict target that is not the key or a declared
.unique;.acrosswith a negated operator;.min/.maxover abool,Uuid,BytesorJson; a list column ofTimestamp,Date,Decimal,BytesorJson; au64,usize, an integer past 64 bits or a float other thanf32andf64as a field;nullinside an.inlist; on SQLite, ordering, comparing or summing aDecimal; two indexes sharing a.name; an unquoted capitalised function name;.unique = ""; a cron schedule whose day and month never meet; asession_token_maxover 2,048.
3. If you wrote your own
- A
nilo_jobstore:done,retry,deadandreleasetake the claim'sattemptsand answer!bool(match only a row stillrunningunder that claim);doneanddeadtakenowlast;retryDeadtakes the scheduled kinds; and there is a tenth method,unkey.job.Enqueuegains an optionalnow.push(.within)needs a window withdelas well asputIfAbsent. - A Space for
Idempotent: it needsputIfAbsentFor(key, value, ttl_s).cache.Spacehas it. - A
nilo_sqlWire or Dialect: a Dialect definesintrospect_allandreads,offsettakes(placeholder, limited); a Wire definescolumnsOfMany,labelsOfManyanddescribe(arena, sql, nulls)(answer null to stay unchecked),Tx.committakes(arena, problem), andenum_valuestakes its type names as an array. - A program that also depends on zio: pass
.scheduling = .pinnedto your ownb.dependency("zio", …), or it builds a second, differently configured zio beside nilo's.
What is new
nilo_proto, a twelfth module: protobuf as plain structs. A message is a struct withpub const wire = .{ .id = 1, ... }, andproto.decodeandproto.encodedo the rest, with no generator, no.protofile and a compile error naming the field for every mistake in a type. Decoding an OTLP logs request runs within 0.4% to 2.6% of a decoder written by hand. It needs no event loop, so it runs anywhere,@import("nilo_proto")is all it takes, and the tracing below is built on it (guide).- Routes per listener and limits from configuration.
app.onListener(&.{1})binds routes to one address andc.listener()says which one a request came in on;nilo.maxBody(&limit)reads a route's body cap from a variable filled at startup, gRPC calls included. - Tracing.
app.trace(.{ .service = "orders" })makes every request a span, joins an incomingtraceparent, carries it intonilo_fetchcalls, and sends OTLP to a collector. - Safer defaults in one line.
nilo.secure.api(.{})andnilo.secure.pages(.{})set the security headers a browser reads as policy. - Rooms for every connection. A WebSocket and an event stream share a Room,
nilo.Roomslends a Room to a key such as"user:42"to reach every tab, and a Room with.historycatches a returning stream up fromLast-Event-ID.c.eventsFromholds an event stream for the cost of an idle connection. - A Row that reads more without raw SQL.
db.feedwith an.aftercursor an index seeks on,nilo_throughfor a column of another table, ordered, filtered and counted children, an aggregate with its own.where,.unreadcolumns,db.explain, a PATCH in one.set,sql.violatedto tell which unique fired, andsql.UnixMillis. - JSON bodies.
pub const nilo_json = .{ .misfit = 422 }answers a body of the wrong shape with 422, and.unknown_fields = .ignorelets a type skip keys it does not know. - S3.
bucket.putMultipartfor a stream of unknown length,presignPut, andBucket.openAsfor a bucket named at run time. - Testing.
testing.Liveruns a real server on a free port in a test, andtesting.tmpDir()gives a directory with paths for code that opens files by name. - Faster where it was slow. gzip through libdeflate behind
.libdeflate = true(a quarter to a third of the time), a gRPC call on its connection's own thread (2.7x), a cached Postgres statement in one round trip, and a shortnilo.blockingcall that no longer waits behind a long one.
The full list, every entry with its ADR, is CHANGELOG.md at v0.7.0.