Skip to content

v0.7.0: wrong answers closed, and what a port needed

Choose a tag to compare

@nevindra nevindra released this 02 Oct 12:49
· 84 commits to main since this release

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 session max_age, so the old cookie is still read while each visitor's next set moves them to the new name.
  • app.guard(…, "session") becomes app.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, another path or secure = false cannot use the prefix: it keeps the plain name and needs the same option, or its set fails 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.0 where it was 1, 1e+16 where it was seventeen digits, an f32 from its own digits (1.1, not 1.100000023841858). A test holding "ratio":0 now sees "ratio":0.0.
  • A sql.Timestamp always carries six fractional digits (2026-08-16T09:30:00.700000Z), and a Timestamp or Date outside 0001 to 9999 is an error rather than null. 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 update or delete whose condition matches every row (an empty .not_in, .contains = "", a .like of only %), and a negative .limit or .offset, refused before they are sent. A call that really means every row uses db.raw.
  • insertOrIgnore and insertOrUpdate with a conflict target the values do not carry, and updateReturningOne with a .where that 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.applyPending when 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 = .now to a table that exists, now a Problem in the plan.
  • rawPage with an OFFSET nilo cannot set back to 0: write OFFSET $n and work the number out in Zig.
  • on SQLite :memory:, a db.raw that writes but begins like a read, since readers are opened query_only.
  • cache.Space.open for a flat value no shard could hold, which now panics naming the type and the limit, where every put was 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 by s3.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 /, a Host that 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.0 for an integer, "1_0"), and an Idempotency-Key too long for the route's Space, all 400; HTTP/2.0 on 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.send and the stream and upgrade calls gain AlreadyAnswered; a Body read gains BodyTruncated; url.into gains BadValue; Room.Error gains WriteFailed and EventFieldBreaksLine and loses AlreadySeated (a socket may now sit in several rooms, and Socket.ticket(), inRoom(), seatedIn() and unseat() are replaced by seating() and leaveRooms()); Space.incr and Store.add can fail with TooLarge; Jobs.Error gains Scheduled and EmptyUniqueKey; cache.OpenError gains SeedUnavailable; s3.OpenError gains BadCredentials, BadRegion, BadEndpoint and BadStreams; wire.Error gains RolledBack; migrations.Error gains SnapshotBehind, and migrate.Kind gains .rename_index and .rename_constraint; SQLite's error.PrivateMemoryDatabase is gone, an empty URL being error.EmptyDatabaseUrl.
  • Signatures: nilo.maxBody returns an mw.Limited (a variable typed Middleware uses its .run); c.peer() returns *const Peer; static.Set.fallbackFor and static.navigational take static.Asked; generate --drop names what it drops and migrations.Options.allow_destructive is .drop.
  • Schema and query shapes nilo now refuses while compiling: an upsert conflict target that is not the key or a declared .unique; .across with a negated operator; .min/.max over a bool, Uuid, Bytes or Json; a list column of Timestamp, Date, Decimal, Bytes or Json; a u64, usize, an integer past 64 bits or a float other than f32 and f64 as a field; null inside an .in list; on SQLite, ordering, comparing or summing a Decimal; two indexes sharing a .name; an unquoted capitalised function name; .unique = ""; a cron schedule whose day and month never meet; a session_token_max over 2,048.

3. If you wrote your own

  • A nilo_job store: done, retry, dead and release take the claim's attempts and answer !bool (match only a row still running under that claim); done and dead take now last; retryDead takes the scheduled kinds; and there is a tenth method, unkey. job.Enqueue gains an optional now. push(.within) needs a window with del as well as putIfAbsent.
  • A Space for Idempotent: it needs putIfAbsentFor(key, value, ttl_s). cache.Space has it.
  • A nilo_sql Wire or Dialect: a Dialect defines introspect_all and reads, offset takes (placeholder, limited); a Wire defines columnsOfMany, labelsOfMany and describe(arena, sql, nulls) (answer null to stay unchecked), Tx.commit takes (arena, problem), and enum_values takes its type names as an array.
  • A program that also depends on zio: pass .scheduling = .pinned to your own b.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 with pub const wire = .{ .id = 1, ... }, and proto.decode and proto.encode do the rest, with no generator, no .proto file 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 and c.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 incoming traceparent, carries it into nilo_fetch calls, and sends OTLP to a collector.
  • Safer defaults in one line. nilo.secure.api(.{}) and nilo.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.Rooms lends a Room to a key such as "user:42" to reach every tab, and a Room with .history catches a returning stream up from Last-Event-ID. c.eventsFrom holds an event stream for the cost of an idle connection.
  • A Row that reads more without raw SQL. db.feed with an .after cursor an index seeks on, nilo_through for a column of another table, ordered, filtered and counted children, an aggregate with its own .where, .unread columns, db.explain, a PATCH in one .set, sql.violated to tell which unique fired, and sql.UnixMillis.
  • JSON bodies. pub const nilo_json = .{ .misfit = 422 } answers a body of the wrong shape with 422, and .unknown_fields = .ignore lets a type skip keys it does not know.
  • S3. bucket.putMultipart for a stream of unknown length, presignPut, and Bucket.openAs for a bucket named at run time.
  • Testing. testing.Live runs a real server on a free port in a test, and testing.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 short nilo.blocking call that no longer waits behind a long one.

The full list, every entry with its ADR, is CHANGELOG.md at v0.7.0.