Repository navigation
v0.28.0
Breaking
An index, not a second copy: every item below is tagged **BREAKING (...)** on
the entry itself, which is where the reasoning lives. Nothing here is a new
fact. It exists because 35 breaking items are spread across four sections of a
long release, and twelve of them change what a query answers with no error
raised at all, which is the group an upgrading user gets no signal about.
cargo-semver-checks runs zero lints on a 0.x MINOR bump, so the Rust API
changes below get no automated verdict on this release. This list is the record.
Answers or written data change, with no error raised. Re-check anything that
compares floats, sums floats, ranges over a bytes column, measures string
length, negates a filter on an optional column, reads result columns by name, or
keys an update / delete on a datetime, uuid or bytes column:
ChangedA comparison against NaN follows IEEE.ChangedFloatsumandavgare accumulated with compensation.ChangedAn ordered comparison on abytescolumn no longer uses the index.Changedlength()counts characters, not bytes.Changednotover a missing value is the plain complement (rows that were
dropped are now kept).ChangedA projection column is named after what it computes (result column
names move:?and__agg_0become real names).ChangedA number binds asintwhen it is integral and inside the signed
64-bit range (@zvndev/powdb-client).FixedA repeated query no longer answers a different question (a cached
query's second and later executions were computing something else).FixedA comparison against adatetime,uuidorbytescolumn coerces its
literal. Anupdateordeletekeyed on such a column was a silent no-op
and now writes rows.Fixedunionde-duplicates both branches (row counts fall).FixedAn ordered comparison across two types is false (rows of another type
are no longer returned).Fixedx in (...)answers whatx = ...answers.Fixedlength()on abytescolumn answers its byte count (it was null
on every row, with no error).
Statements, directories or deployments that used to be accepted are now
refused. These announce themselves, but they fail work that previously ran:
Fixedlength()on anint,float,bool,datetime,uuidor
jsonoperand is a typed refusal naming the column and its type.ChangedAn arithmetic result with no int64 answer is an error, not the
missing value.ChangedAnintcolumn refuses a float that is not exactly that integer.ChangedAn unknown backslash escape in a string is a parse error (catches
Windows paths and regexes written in literals).ChangedA cast literal against a mistyped column is an error.Changedhavingandlikerefuse a mistyped operand.ChangedA pipeline clause that carries a value may be written once.ChangedA supplied parameter the query never references is an error.ChangedA type no row of which could ever be stored is refused attypeand
atalter ... add column.Changeddrop <name>on a materialized view is refused; usedrop view.ChangedA damaged data directory refuses to open instead of opening smaller.ChangedWAL damage inside the log refuses the open.ChangedA malformedPOWDB_*value refuses startup with exit 2. A
deployment carrying a typo (POWDB_READONLY=ture) used to start and serve the
directory writable.Changedpowdb-cli --execexits 1, not 0, when a script ends inside an open
transaction.Fixed@zvndev/powdb-embeddedrejects arguments it used to coerce: an
undefinedparameter no longer binds a PowQL null, and aNaNmemory limit
no longer opens with a zero-byte budget.SecurityA data directory the owner cannot open is refused rather than
widened; 0000 is refused outright.
API, wire and operational contracts:
ChangedA corrupt page read fails closed. Rust:HeapFile::get,
Table::getandTable::index_lookupreturnio::Result<Option<..>>, so
every caller of the three must handle the error arm. A point lookup on a
rotted page raisesPageCorruptwhere it answered zero rows.ChangedEngine error text reaches remote clients verbatim. Message text
changes for everything that used to arrive as the barequery execution error,InvalidIdentifiermoves from wire class 0 to 2 andRowTooLarge/
ValueTooLargefrom 0 to 4, and Rust callers of
powdb_server::protocol::Message::decodegetResult<Message, DecodeError>
where they gotResult<Message, String>.ChangedA served sync pull chunk stays inside the wire envelope every
released peer decodes. This makes the 4096-unit cap a hard product ceiling of
about 4094 row changes per transaction; a larger transaction is answered with
a typed rebootstrap rather than served. See
docs/embedded-sync.md.Changed@zvndev/powdb-sync:LocalApplyRequest.primaryGenerationis
bigint, a compile break for an adapter implementer, and
DEFAULT_MAX_PULL_UNITSmoves from 512 to 4096.Fixed@zvndev/powdb-embedded: engine failures carry the wire error class,
soerr.codevalues change. Anything matching the flatquery_failedmust be
updated, as it was for the same package in 0.26.0.AddedSIGHUP reloads the user store. The server installed no SIGHUP handler
before, sokill -HUPterminated it; it now reloadsauth.jsonand keeps
serving.FixedBackups now carryauth.json, so a server started on a restored
directory enforces authentication where it previously accepted
unauthenticated connections.SecurityA shared-password deployment counts auth failures against the peer,
not the username. Unix-socket peers are rate limited for the first time and
share one bucket, a shared-password server drops from 50 to 5 guesses a minute
per peer, andpowdb_server::handler::AuthRateLimiter's underlying type
changed.
Added
-
drop link <Owner>.<name>, also spelledalter <Owner> drop link <name>, both withif exists. A declared entity link could be created but
never removed short of dropping the table. -
powdb-cli --readonlyopens an embedded data directory through
Engine::open_read_only, so a shell or a script can read a directory it must
not write.--readonlytogether with--remoteis a usage error naming the
server-side setting, and a read-only open never creates the directory. A
missing--data-dir, or one that names a file, is reported as such instead
of surfacing a bare OS error. -
powdb-cli --remoteaccepts a Unix-domain socket path. A path (rather
thanhost:port) connects over the socketpowdb-server --socketpublishes;
before this it failed withconnection failed: invalid socket address, so
the CLI could not reach a socket-only server at all. -
powdb-cli --password-stdin, accepted wherever--passwordis, and
refused alongside it. Only the line terminator is stripped, so a password
ending in spaces still works.--passwordnow warns that the value is
visible inps, without printing it. -
Per-subcommand
--helpinpowdb-cli.backup --helpused to run the
backup and fail on a missing data directory. Every subcommand answers its own
help from the same strings that back the top-level--help. -
--wal-checkpoint-bytes <BYTES>onpowdb-server(env
POWDB_WAL_CHECKPOINT_BYTES) and onpowdb-cli's embedded path, with
Catalog::set_wal_checkpoint_bytes,Catalog::wal_checkpoint_bytesand
powdb_storage::catalog::DEFAULT_WAL_CHECKPOINT_BYTES(64 MiB) behind them.
The threshold is a plain byte count with no unit suffix;0disables the
automatic checkpoint and restores the grow-until-close behaviour. It has no
effect in either shipped binary yet: the automatic checkpoint is skipped
whenever a WAL archive hook is installed, and bothpowdb-serverand
powdb-cliinstall one unconditionally, so the log still grows until an
explicit checkpoint or the close. Embedded callers that open without an
archive hook get the bound. -
A TLS certificate's expiry is read at startup.
powdb-serverwarns when
the certificate it is about to serve has expired or is close to expiring, and
a certificate or key it cannot use is refused with a message naming what is
wrong (an EC key with explicit curve parameters names the command that
re-encodes it, a duplicatebasicConstraintsextension is named). -
BREAKING (operational): SIGHUP reloads the user store without restarting the server, so a
password rotated withpowdb-cli passwdtakes effect on the next
authentication and a deleted user stops being able to log in.powdb-cli's
user-admin subcommands now say so when a server holds the data directory. -
powdb_sync::archived_through_lsn: the highest LSN the retained archive
carries, answered from segment file names alone, with no lock taken. -
powdb-syncandpowdb-backupemittracingspans and events: what a
backup copied, which increments a restore chain applied, where a blake3
mismatch was found, and a warning when the sync-aware checkpoint in
SyncCatalog::dropfails. That last one previously swallowed the error
entirely, so retained history stopping short left no trace anywhere. -
docker run <image> --versionworks. The image's entrypoint routes a
leading-argument topowdb-server, so the documented flags can be passed
straight todocker runwithout repeating the binary path. A first argument
that is not a flag is still executed as its own command, so
docker run <image> shand the compose overrides inexamples/deploy/
behave exactly as before. -
The published ghcr image is tagged with the bare version. A
vX.Y.Ztag
now also producesghcr.io/zvn-dev/powdb:X.Y.ZalongsidevX.Y.Z,X.Y,
Xandlatest.examples/deploy/and the docs have always pinned the bare
form; until now that tag did not exist for a fresh release until the floating
minor tag happened to line up. -
The publish workflow builds and smoke-tests a release binary before it
publishes anything.publish.ymlruns the samescripts/smoke-release.sh
the release workflow runs, on the same commit, before the first crate goes
out. crates.io publishes are irreversible, so the check that a release binary
actually starts, serves a query and replays its WAL now happens on the near
side of the door rather than minutes later in a parallel workflow. -
post-publish-smoke.ymlchecks all four channels. It verified the CLI and
the two npm packages; it now also installs thepowdbembedded facade crate
from crates.io and runs a query through it, asserts the npm packages'
dist-tags and peer pins, pulls the ghcr image for both architectures and runs
--versionunder emulation, and verifies the GitHub Release's assets exist,
are non-empty, match their published checksums and carry a valid build
provenance attestation. -
New CI gates, each with a
--selftestthat CI runs immediately before the
real check:scripts/ci/semver-gate.sh(cargo-semver-checks really linted
something),scripts/ci/check-dependabot-coverage.sh(every tracked
dependency manifest is watched),scripts/ci/strip-empty-unreleased.sh, and
scripts/ci/check-tool-pins.sh(everycargo installin a workflow carries
--versionand--locked, a tool installed by two workflows is pinned to the
same version in both, and a scan that matched no install at all fails). Four
--selftestentry points run as their own workflow steps:
semver-advisory.sh(4 cases),check-ci-success-needs.sh(14),
internal-content-guard.sh(6) andcheck-tool-pins.sh(8). -
The MSRV job runs the tests. It compiled the workspace on the MSRV
toolchain and stopped there, so an MSRV-incompatible construct anywhere in a
#[cfg(test)]block or a dev-dependency was invisible. The clippy, ASan,
fuzz-replay and examples-smoke jobs also gained a second operating system. -
clients/ts/scripts/check-readme-samples.mjsextracts every fenced
ts/jsblock from the three package READMEs, compiles each against the
package's real types understrict, and reports diagnostics at the README
line they came from. Wired up aspnpm test:readmeinclients/tsand
clients/sync. -
bindings/node/CHANGELOG.md, shipped in the npm tarball.
Changed
-
BREAKING (API/wire): A corrupt page read fails closed instead of answering "no such row".
HeapFile::getandTable::getmapped an I/O failure and a CRC refusal to
None, and ten executor sites readNoneas "the row is deleted", so an
indexed point lookup on a page that rotted after the directory was opened
answered zero rows with a success status. Both now return
io::Result<Option<..>>, withOk(None)reserved for a slot that really is
deleted or out of range, and every executor site propagates. The refusal
reaches an engine caller as aPageCorrupterror and a wire client as error
class 0 (internal).Table::index_lookupmoved with them
(io::Result<Option<(RowId, Row)>>). See
docs/STABILITY.md. -
BREAKING (now refused): An arithmetic result with no int64 answer is an error, not the missing
value.+,-and*that overflow, and/by a divisor that is zero for
some row, used to evaluate to the missing value: indistinguishable from a
missing column, and anupdatewrote it into a required column. All four now
refuse the statement, and anupdatethat hits one writes no row at all. -
BREAKING (silent): A comparison against NaN follows IEEE.
=,<,>,<=and>=
against NaN are false and!=is true, on every access path. Ordering,
group,distinctand index key order still place NaN in one definite
position, and a join on a NaN key still matches. -
BREAKING (silent): Float
sumandavgare accumulated with compensation. The same rows
summed through a sequential scan and through an index gave two different
numbers on a column that mixes very large and very small values, and neither
was the exactly rounded total. Every float accumulator in the engine now
carries the lost low-order bits, so a float aggregate is the same number on
every access path. Reported float aggregate results may move, always
towards the exact answer. Integer sums are unchanged. -
BREAKING (now refused): An
intcolumn refuses a float that is not exactly that integer.30.7
was stored as30,-0.5as0and1e300asi64::MAX, on insert and
update alike. -
BREAKING (silent): An ordered comparison on a
bytescolumn no longer uses the index. The
B+tree orders bytes keys by length before content, so an index-backed
.y >= "\\x0102"silently dropped every stored value of a different length,
including the row holding\xff.<,<=,>and>=on a bytes column
are answered by the scan, which orders bytes bytewise as every other path
does. Equality still uses the index, so a point lookup keeps its plan; a range
over an indexed bytes column now plans a filtered scan and costs what a scan
costs. -
BREAKING (silent):
length()counts characters, not bytes.length("café")was5, and
disagreed withsubstring, which has always been character-indexed. -
BREAKING (now refused): An unknown backslash escape in a string is a parse error, in PowQL and in
the SQL frontend alike, in string literals and in quoted identifiers. The
lexer used to drop the backslash and keep the rest, so"a\Ab"was stored as
aAband a quoted SQL identifier"i\d"silently resolved toid. The
escape set is\',\",\\,\n,\t,\r,\0and\uXXXX. One
consequence: abytesliteral must be written with the doubled-backslash
form ("\\x0a"), which was always the only form that worked. -
BREAKING (now refused): A cast literal against a mistyped column is an error where it was silently
false..u = bytes("...")on a column that is notbytesused to match no
row and say nothing. -
BREAKING (now refused):
havingandlikerefuse a mistyped operand..n like "abc"on an int
column,.s like 5, andhaving count(.id) = "x"used to return no rows;
they now raise the same typed errorfilterraises. -
BREAKING (now refused): A pipeline clause that carries a value may be written once.
User filter .a = 1 filter .b = 2used to keep only the second predicate and
answer as if the first had never been written; the same held fororder,
limit,offset,groupand the projection block. Writing one twice is now
a parse error. Repeatedhavingstill chains withand. -
BREAKING (now refused): A supplied parameter the query never references is an error. Binding three
parameters to a query that mentions$1and$2used to run with the third
silently ignored, which hides a caller's off-by-one instead of reporting it. -
BREAKING (silent):
notover a missing value is the plain complement.not (.x = 1)on a
row where.xis missing was itself missing, so the row was dropped. PowQL's
filter logic is two-valued everywhere else, and now here too: the row is kept. -
BREAKING (silent): A projection column is named after what it computes.
D { .j->a, .j->b }
came back as two columns both called?, and a grouped aggregate written
without an alias was headed by the planner's internal__agg_0. SQL names an
unaliased aggregate the way SQL does:count(n). -
BREAKING (now refused): A type no row of which could ever be stored is refused at
typeand at
alter ... add column. Declaring several hundred columns used to succeed
and then fail on every insert, including inserts that set no value at all,
because the empty row already exceeded the page budget. The refusal says how
many bytes the columns need and how many a row may use. -
BREAKING (now refused):
drop <name>on a materialized view is refused; usedrop view <name>.
It removed the backing table and left the definition behind, so the view
stayed registered, still went dirty on writes to its source, and reported a
missing table on every read, across restarts. -
BREAKING (now refused): A damaged data directory refuses to open instead of opening smaller. A
heap whose length is not a whole number of pages was rounded down, so
truncate -s 100on a 500-row table gave a table with no rows and no error. A
catalogued table whose heap is zero bytes was the same defect with a length
that divides evenly. And acatalog.binmissing beside surviving heap and
index files reportedNotFound, which every caller reads as "fresh
directory", so a new catalog was created next to the data and every table
vanished; that is nowCatalogCorruptnamingcatalog.bin, while a genuinely
empty directory still reportsNotFound. -
BREAKING (now refused): WAL damage inside the log refuses the open; only a torn tail is
truncated. A CRC failure mid-log returned the prefix before it and the log
was then truncated with nothing logged, which is data loss dressed as
recovery. Replay now looks for a valid record after the bad one, first where
the length field says it ends and then with a bounded byte scan. If one is
there the damage is inside the log and the open refuses; if nothing is there
it is the torn tail of the write that was in flight, which warns with the
offset and the discarded byte count and truncates as before. A WAL whose file
header is notPWALwas read as a pre-0.5.0 headerless log, silently dropping
every un-checkpointed row; a directory whose catalog or heap format is past
that era now refuses. -
BREAKING (now refused): A malformed
POWDB_*value refuses startup. A typo in a deployment's
environment used to run the server on a default nobody chose. Every setting
now goes through the same validator as its flag and exits 2 naming the
variable and the value.0is refused forPOWDB_IDLE_TIMEOUTand
POWDB_QUERY_TIMEOUT;POWDB_TX_MAX_LIFETIME_MS=0keeps its documented
meaning of disabling the bound. A refusal also names the unit the setting is
actually in (connections, bytes, candidate pairs, pages) rather than calling
every one of them a byte count. -
The user store is re-read while the server runs.
auth.jsonwas read once
at startup, so a password rotated withpowdb-cli passwddid not take effect
and a deleted user could keep logging in until a restart. The server stamps
the file by mtime and length and re-reads it when it changes, and SIGHUP
forces a reload. The check is a stat on a path that is already rate limited. -
A begin-only explicit transaction no longer blocks readers.
begintook
the write-admission gate outright, so a connection that had opened a
transaction and not yet written made every other connection's read wait for
it, up toPOWDB_TX_WAIT_TIMEOUT_MS. Reads are now admitted from a second
pool that only readers take from, tied to the transaction's own hold so it
cannot leave the door open for the next holder of the gate. The transaction's
first write closes that pool and drains it, which waits for the reads already
running and for nothing else. A read during a transaction that has written
still waits, and a secondbeginand an autocommit write still wait as
before. -
BREAKING (API/wire): Engine error text reaches remote clients verbatim, classified by type.
Egress rendering was a prefix allowlist over the message text, so any
diagnostic whose wording did not start with one of about 35 recognized
phrases arrived as the bare stringquery execution error: a mistyped column,
commitoutside a transaction, a malformeduuidorbytesliteral, a
missing required column, an unknown insert field, a negative limit, an upsert
on a non-unique key, a non-scalar path-index key, a refresh of an unknown
view. The same statement was diagnosable embedded and undiagnosable over the
wire. The decision now runs exhaustively on theQueryErrorvariant, so
rewording a diagnostic can never silently mask it. Only messages that can
carry internal state are still redacted: a plain I/O failure, corruption
detail, and the read-only retry sentinel. Two classes moved with it:
InvalidIdentifierreaches clients as class 2 andRowTooLarge/
ValueTooLargeas class 4, where all three were class 0 (a server fault with
nothing to fix on your side) for what is the caller's own data. A frame-level
refusal now answers with anErrorframe carrying its class rather than only
being logged before the close. -
The in-flight read-ahead byte budget defers a frame rather than buffering
it. The read-ahead arm passed the 64 MiB wire limit as its frame bound, so
the 1 MiB budget only decided whether a read may start: a peer that opened a
statement and then announced a 64 MiB frame buffered all 64 MiB of it on that
connection, per connection, andVec::drainnever returns the capacity. A
frame that does not fit the remaining budget is now neither refused nor lost:
its header stays buffered, read-ahead pauses for the rest of the statement
exactly as it does when the queue is full, and the main loop reads the whole
frame at the full wire limit as soon as the statement finishes. -
BREAKING (API/wire): A served sync pull chunk stays inside the wire envelope every released peer
decodes. Chunking was changed this cycle so a chunk runs on to the commit or
rollback that closes the transaction the cut would otherwise land inside,
because a chunk cut inside a transaction is not applyable and wedged the
replica for good. The chunk is capped at 4096 units: every decoder through
v0.27.0 refuses aSYNC_PULL_RESULTdeclaring more than that, and a v0.27.0
replica pulling a larger chunk got a frame-level decode failure that drops the
socket with no diagnostic and repeats on every retry. A transaction that does
not fit is answered with the rebootstrap status the byte budget already
produces, naming it, rather than a bare error the replica retries forever. A
chunk still runs past the replica'smaxUnitshint up to that cap, so the
1000-row transaction the hint-not-a-limit rule was written for is still served
whole. The server's own decoder ceiling is raised from 4096 to 262144 units to
matchclients/ts/src/protocol.ts, so what a v0.28 peer accepts and what a
v0.28 primary sends are now two separate numbers: raising what is sent needs a
negotiated wire feature. -
BREAKING (now refused):
powdb-cli --execfails a script that ends inside an open transaction. It
exited 0 while every write the script made was discarded. It now exits 1 with
transaction still open at end of script; rolled back. A script that commits
or rolls back explicitly is unaffected. -
sync-statusreportsawaitArchiveas a momentary state, not an error. A
replica waiting for the primary to archive has not failed, and printing it
underlastSyncErrorread as one. Arebootstrapstill reports its error. -
BREAKING (silent): A number binds as
intwhen it is integral and inside the signed 64-bit
range, in@zvndev/powdb-clientas it already did in the embedded addon.
The wire client tagged a numberintonly whenNumber.isSafeIntegerheld.
Every double in[2^53, 2^63)is an exact integer, so theinttag is
lossless there, and the float tag cost the index: measured against a running
server,filter .id = $1with2 ** 60on arequired unique id: int
plannedFilter(SeqScan)over the wire andIndexScanin process.
Snowflake-shaped ids sit squarely in that range.2 ** 63and above still
bind float. -
@zvndev/powdb-clientgains amaxInFlightconnect option (default 64)
and theinvalid_argumenterror code.queryTyped's generic is no longer
constrained toTypedRow: the bound rejected theinterfacemost callers
declare a row type with, while claiming a check the method never performed. -
BREAKING (API/wire):
@zvndev/powdb-syncraisesDEFAULT_MAX_PULL_UNITSfrom 512 to 4096, and
LocalApplyRequestextends the newNormalizedSyncIdentityso
primaryGenerationisbigint, matching what a replica already passes. -
@zvndev/powdb-embeddedis entered throughloader.jsrather than the
generatedindex.js, and exportsSUPPORTED_PLATFORMSand
ERROR_CLASS_BY_CODE. Anything that bypasses the loader loses the error
classification. -
@zvndev/powdb-syncbuilds on the same TypeScript and@types/nodemajors
as@zvndev/powdb-client; both declaresideEffects: false. -
The published
powdb-querycrate no longer ships the executor's unit
tests, about 300 KB of#[cfg(test)]source in every download. -
Internal, CI and release engineering. Dependabot watches every dependency
manifest: it watched 3 of the 7, andclients/sync,bindings/nodeand
crates/query/fuzzwere unwatched while the Dockerfile's base images were
pinned with nothing to move them; a new check fails if that drifts again in
either direction. Third-party tooling in CI is pinned and
checksum-verified: gitleaks and cargo-semver-checks are verified with
sha256sum -c(the semver-checks fetch no longer pipescurlintotar,
which cannot verify anything), cargo-fuzz is pinned, andcargo auditruns
from a pinned, cached binary rather than a source build of a floating
version, with the audit suppression living only in.cargo/audit.toml.
The miri job is sharded five ways and its coverage is asserted: it was one
job filtering on a module prefix, which is how it once spent months filtering
on a module that did not exist, and a new check runs the real test list
through the shard map and fails if any in-scope test is claimed by no shard or
by more than one. The Docker builder image is digest-pinned, like the
runtime image already was, and the Docker healthcheck no longer logs an
error every 30 seconds (the probe read the PONG frame's header and closed,
leaving bytes in the receive queue, which makes the kernel send RST instead of
FIN; it now drains the frame and closes cleanly). Publish workflows read
their dispatch inputs through environment variables rather than
interpolating them into shell text. Concurrency groups only cancel in-flight
runs on pull requests, so a push tomainor a tag build can no longer be
cancelled by the next push. The published npm packages no longer carry an
empty## Unreleasedheading in their bundled CHANGELOGs; a section with
content in it is left alone.
Fixed
-
The release-body check rejected a release section larger than a pipe
buffer.changelog-section.shtested for content withprintf | grep -q
underpipefail;grep -qexits on its first match, and once the section
outgrew the pipe buffer (this one is 68 KB)printftook SIGPIPE and the
check reported the fullest section in the file as empty. Found cutting this
release; both it andcheck-version-consistency.shnow test a here-string. -
BREAKING (silent): A repeated query no longer answers a different question. The plan cache
collected a query's literals in source order and re-bound them in plan-walk
order, and the two disagree wherever the planner reorders clauses. The
everyday case was a projection above a slice:T { x: .n + 1 } limit 5
computed.n + 1on its first execution and.n + 5on every one after, and
T filter ... { x: .n + 1 } limit 5 offset 0silently started skipping five
rows. A projection whose literal cannot be ordered against a sort key's is no
longer cached at all. -
BREAKING (silent): A comparison against a
datetime,uuidorbytescolumn coerces its
literal..u = "<the exact uuid>"was false on every row,!=true on
every row,updateanddeleteby such a key touched nothing, and the index
was never probed. The literal is now coerced once, where the plan enters
execution, so the scan, the compiled predicate, the index probe and the
mutation's discovery scan all read the same value; a literal that cannot be
coerced is the typed errorinsertalready gives. -
BREAKING (silent):
unionde-duplicates both branches.A union Bkept duplicate rows that
came from the left branch. -
BREAKING (silent): An ordered comparison across two types is false.
.j->v > 99.5returned
the rows whose value was the string"deep"and the booltrue, because the
fallback ordering ranked the two values by type. -
BREAKING (silent):
x in (...)answers whatx = ...answers. The list compared with strict
variant equality while=compares numerically, so.n in (28.0)and
.n = 28.0disagreed. -
A float literal can be written with an exponent.
1e9,1.5E-3and
2e+10were lexed as an integer followed by an identifier, so a query using
scientific notation failed with a confusing parse error. A literal whose value
is not finite is refused rather than becominginf. -
A materialized view over an empty source is typed from its source. Every
column came backstr, so the first refresh that produced a real row failed
with a type error the user could not act on. -
Refreshing a materialized view reclaims the space the last one used. A
view's backing table grew on every refresh, because deleted rows never
returned their bytes. -
A materialized view tracks its source through nested projections and link
traversals. Those dependencies were not recorded, so writes to the source
left the view clean and it served stale rows. -
A
views.binthat cannot be decoded refuses the open. It was read as "no
materialized views", so everymaterializeever run vanished and the next one
overwrote the file that still held the definitions. -
A query that reads a table that does not exist says which table.
count(Missing)reported the storage layer's generic error and a join against
a missing table reported a missing column; every read shape now reports the
same typed "table not found" naming the table. It carries
StorageErrorKind::TableNotFound, so the same mistake no longer changes wire
error class depending on which layer noticed it (class 2 either way). -
Calling a function PowQL does not have says so.
T { x: foo(.id) }
reportedcolumn 'foo' not foundandT filter foo(.id) = 1reported an
unexpected(. Both now name the function, with a suggestion when the name is
close to a real one. -
A link traversal outside a projection names the limitation.
Post as p filter p.author.name = "ann"reported an unexpected trailing
token; it now says a link path is only usable in a projection. -
A prepared write inside a transaction no longer fsyncs at every statement.
The four prepared-statement commit points called the autocommit commit
unconditionally, so a statement insidebegin/commitflushed and fsynced
the WAL at every statement boundary where the identical rows written as PowQL
text did not. 5000 single-row inserts through a prepared statement inside one
transaction cost 5000 fsyncs where the text path cost 78. The two paths now
cost the same. -
PRIMARY KEYon a column works in SQLCREATE TABLE, mapping to
required unique; a table-levelPRIMARY KEY (col)now names the spelling
that works instead of a message about table constraints. -
SQL
CREATE TABLEaccepts theJSON(andJSONB) column type. The JSON
examples indocs/SQL.mdwere unreachable through SQL DDL. -
Two processes could open one data directory for writing. The writer
directory lock published its PID file first and took the kernelflock
afterwards, over whatever inode theLOCKpath had come to name, and then
discarded the answer. Two servers started against the same--data-dir(a
systemd restart overlapping the old process, two containers on one bind mount)
could both be admitted, both opening the same heap and the same WAL. The lock
is taken first and is what admits a writer: the PID is written in place
through the locked handle, so it never names a process that does not hold the
directory, and a lock that is already held is a refusal rather than a shrug. A
lock on an inode the path no longer names is not accepted, so a previous
holder's cleanup cannot hand the directory to two acquirers at once. ALOCK
naming a dead or ghost PID whose lock is free is still reclaimed, so the
container and PID-namespace case the lock file was extended for still works.
On a filesystem with no advisory locking at all (some network mounts) PowDB
logs a warning and falls back to the PID heuristic rather than refusing to
open. -
insertcould spin forever on a corrupt heap page, wedging the server.
Page::compactrefuses a page whose slot directory points outside it, and the
free-space search re-offered the page on the strength of the dead space it
could never reclaim. One insert into such a table looped inside the engine
write lock and blocked every other reader and writer until the process was
restarted, whereupon it happened again. A page that cannot be compacted now
leaves the free list: the rows it holds stay readable, and it is never offered
for new ones. -
A statement could report a durable write as failed. The automatic
checkpoint that runs after a commit is housekeeping, but its failure was
returned through the commit's own result. An I/O failure there (a filesystem
that has just filled is the obvious one) told the client the write had not
happened when it had, and a retry on a table with no unique constraint stored
the row twice. The checkpoint failure is now logged, not returned; the commit
is durable before it runs. -
The automatic checkpoint could truncate a WAL somebody was archiving. It
skipped only when PowDB's own sync was enabled, butEngine::new_with_wal_archive
installs an arbitrary archive hook, and such a caller has no.powdb-sync
directory. After enough writes the next commit truncatedwal.logwithout
running the hook, losing every record since the last explicit checkpoint,
silently. A catalog opened with an archive hook no longer runs the automatic
checkpoint at all. -
insertcost no longer grows with the size of the heap. The free-space
search kept one flat list of every page with 64 bytes free and walked it from
the front, installing each candidate until one happened to fit. For a 1 KB row
a page holds three rows and keeps about 990 bytes free, which is over the
threshold and under the row size, so every page in the heap stayed on that
list and every candidate was a guaranteed miss: placing one row cost one page
install per page in the heap. A size-segregated in-memory summary (free bytes
per page, 64-byte buckets, lazy deletion of stale entries) now looks only in a
bucket guaranteed to fit. Loading 40,000 rows of 1 KB went from 45.2 s to
68.7 ms, and 100 inserts into that heap from 233 ms to 147 us; at 200,000 rows
the same 100 inserts cost 138 us. No on-disk format change. The summary is
kept exact on every insert, which on a bulk load into a small heap costs about
7 ns per row (Depot, same instance as 0.27.0:insert_10k+5% to +8% across
four runs,insert_batch_1k-53%); deferring the hot page's update to the
moment it is unpinned would remove it and is the follow-up. -
Deleted space is allocatable again.
Page::deleteonly wrote a tombstone,
so a table that was emptied and refilled grew by the full amount a second
time.Page::compactslides live rows to the front of the data area,
preserving slot indices so everyRowIdstays valid, and the free-space
summary counts dead bytes as reclaimable so the insert path compacts a page
just before reusing it. -
A page written before checksums shipped is stamped on open. Such a page
carried no CRC and was never rewritten, so bytes that rotted after the open
scan reached a decoder unchecked, androw_body'sexpecton the row format
version aborted the process underpanic = "abort".row_bodyis now
infallible (thePROWprefix is fixed width whatever the version claims),
HeapFile::getvalidates the row format of what it reads off disk, and the
open scan stamps any page it finds without a CRC on a writable handle, so a
live database never keeps a page outside the checksum. -
The WAL is bounded during a run. It was truncated only by an explicit
checkpoint, which in practice meant only onDrop, so a process that stayed
up through a long write workload carried every record it had ever written and
replayed all of it after a crash. A finished statement now checkpoints once
the durable log passes--wal-checkpoint-bytes(64 MiB by default). An open
explicit transaction, and a directory publishing retained sync history, are
skipped rather than turned into errors, since an insert must not fail because
the log crossed a size. -
WAL replay no longer drops records in silence. A
DdlCreateTablereplay
that failed was swallowed and every laterInsertfor that table was dropped;
it now propagates, naming the table. Records that do not decode, or that name
a table this catalog does not have, are counted, the first one is warned with
its index and its reason, and the count joins the replay summary line.
rebuild_indexes_from_heapskipped a row whose overflow chain would not
reassemble, so crash recovery rebuilt a column index that was silently missing
it. DDL replay discarded the result of the row rewrite and of the page
stamping, so a failed migration came back as a half-migrated table that read
as success and panicked on the next projection. All of them propagate now. -
WAL replay refuses a page id the log could not have produced. Replay
handed a record'spage_idstraight to the insert and overflow-write paths,
which grow the heap one page at a time up to it, so a crafted or corrupt page
id meant a hang and a full disk. Replay now refuses any page id past what the
log could have grown the heap to. The nightly fuzz artifact of 2026-08-30 is
checked in as a seed and refuses in 9.5 ms instead of running forever. -
BREAKING (operational): Backups no longer lose materialized views or the user store. A full or
incremental backup enumeratedcatalog.bin, the catalog LSN sidecar, every
heap and every index, and stopped there. Two durable files live beside them
and were never copied:views.bin, the materialized-view registry, and
auth.json, the user store. A restored directory therefore lost every view
definition (refresh Vanswered "not found", and reads ofVserved whatever
rows the backing heap happened to hold) and lost every user, so a server
started on the restore accepted unauthenticated connections. Both are now in
the manifest set, in the restore name allowlist, and on the incremental path,
and both are optional (copied when present). The durable file names a data
directory holds are now one list inpowdb_storage::data_dir, which the
catalog, the view registry and the directory lock read from, sopowdb-backup
no longer maintains a second copy by hand. A new test walks a data directory
that exercises every durable-file kind and fails on anything the manifest set
does not claim. -
A committed transaction larger than the sync pull window can be pulled.
The chunk was cut atmaxUnits, the cut landed inside the transaction, the
primary refused its own chunk, and every retry cut in the same place: the
replica was wedged for good whilestatusreportedrepairAction: "pull"
withlastSyncError: null. Both sides cappedmaxUnitsat 4096 and the JS
client defaulted to 512, so a 1000-row transaction already wedged it.
maxUnitsis a hint now: the chunk runs on to the commit or rollback that
closes the transaction it is standing in, bounded by the byte budget and the
4096-unit chunk cap. The one case that genuinely cannot be served, a
transaction too large for either bound, answers with a rebootstrap status
naming it rather than a bare error the replica would retry forever. Ack ranges
are bounded separately. -
A live primary archives committed history on demand. Retained segments
were written only by a checkpoint, and the only checkpoint a running primary
performed was on graceful shutdown, so every write on a live primary sat
unarchived and a replica polling it was toldawaitArchiveforever.
sync-statusandsync-pullarchive pending history when a replica asks, and
only when something is unarchived: the archive's own high-water mark, read
from segment file names before any lock is taken, gates it. Before that gate
the demand archive ran a full checkpoint on every frame, under the engine
write lock and with every transaction-gate permit held, so a replica polling
once a second stalled all client traffic once a second against a primary with
nothing new to archive. Measured on a 2000-row database over 20 idle status
polls, the worst wait seen by a thread contending for the engine write lock
fell from 5986 us to 98 us. Archiving is best effort: a checkpoint refuses
while a transaction is open, so the archive stops at the last commit. -
sync-statusreportsrebootstrapwhen the tail holds unapplyable DDL,
with a reason naming it, instead of leaving the replica to discover it one
failed pull at a time. -
@zvndev/powdb-client: a bad parameter no longer desynchronizes the
connection.2 ** 63,1e19,1e300andNumber.MAX_VALUEwere tagged as
ints and threw a raw NodeRangeErrorout ofwriteBigInt64LEafter the
pending entry had been queued, leaving a FIFO slot waiting for a reply the
server was never asked for: the next query hung and later ones took
ECONNRESET. Encoding now happens before anything is queued, an integral
double outside the safe-integer range binds as described under Changed, and an
out-of-rangebigintor a non-finite number is refused synchronously as
invalid_argument. -
@zvndev/powdb-client: a server-initiatedErrorframe keeps its text and
class. An idle timeout, a reaped transaction or "server shutting down"
arrived asprotocol_error: received unexpected frame from server, throwing
away what the server said. Raw socket errors are wrapped asPowDBError
closedwith the Node error ascause. -
@zvndev/powdb-client: an over-cap result no longer poisons the
connection. A result pastMAX_RESULT_CELLS(2,000,000) closed the client
for every later call. It is the one decode failure that leaves the byte stream
intact, so the frame is skipped, only the query that asked for it fails (with
size_exceeded), and the connection stays up. -
@zvndev/powdb-client: a burst no longer dies mid-flight. With no
in-flight cap, 200 concurrent queries answered 22 and then tookECONNRESET
against the server's frame read-ahead budget. Requests past the window wait in
a local FIFO and go out as replies come back. -
@zvndev/powdb-client: the in-flight window bounds bytes as well as
frames. A server's read-ahead budget is 128 frames and 1 MiB, and the byte
cap is the tighter one for anything but small statements: 64 frames of 20 KiB
of query text is 1.28 MiB of read-ahead while the frame count never approaches
128. Against a pre-0.28.0 server, which cancelled the running query and closed
the connection with noErrorframe, that was the exactECONNRESETthe
window was added to prevent. The pump now also stops atMAX_IN_FLIGHT_BYTES
(1 MiB, the server's own cap), and always writes the head frame on an empty
window so one oversized statement cannot deadlock. -
@zvndev/powdb-client:execScriptdispatch is bounded by the in-flight
window.sendstopped writing directly when the window landed: it queues the
encoded frame and the pump writes up tomaxInFlightof them. The dispatch
loop still yielded only on socket backpressure, which the window makes
unreachable, so it never yielded at all: its fail-fast and abort guards could
not fire (a 400-statement script dispatched all 400 after statement 6 failed),
and every frame past the window accumulated in the client's heap (roughly
50 MB for a 10,000-statement script of 5 KB inserts). Dispatch now waits for
the reply that frees each statement's slot. The window still slides, so the
pipeline stays full and throughput is unchanged. -
@zvndev/powdb-client:Poolevicts dead connections. It handed out idle
clients whose socket had already closed, costing one failedwithClientper
idle client after a server restart. The acquire timeout is aPowDBError
codedtimeoutnow, not a bareError. -
@zvndev/powdb-client: a reserved word can be used as an identifier.
escapeIdent("select")returned a bare keyword the engine refused, and it
refused the backticked form too, so no spelling of a reserved table name
worked; it now backtick-quotes a reserved word.escapeSqlIdent, twelve lines
below it, kept returning the bare name, so there was no injection-safe
spelling of a reserved-word table left in SQL either: it now returns the
validated name double-quoted. The SQL frontend has read"..."as an
identifier since 0.23.0 and does no case folding, so this is safe for every
name and needs no second keyword list. A column type is a keyword, not an
identifier, and must not be passed through it.escapeLiteraland
escapeSqlLiteralrange-check bigints instead of emitting an integer literal
abovei64::MAX. -
@zvndev/powdb-client: every abort is aPowDBErrorcodedaborted,
with the abort reason on.cause.ctrl.abort(new Error(...))used to reject
with the bareError, soerr.code === "aborted"did not hold for a custom
reason. -
BREAKING (API/wire):
@zvndev/powdb-embedded: engine failures carry the wire error class.
Every one reached JavaScript as a flatquery_failedwith no class, so an
embedded caller could not tell a unique-constraint violation from a parse
error without matching on message text, while a networked caller could read
the class for the identical failure. Six codes were added (parse_error,
timeout,size_exceeded,readonly_refused,constraint_violation,
cancelled) and every error now carrieserrorClass.Database.openand its
three sibling factories carry it too: the loader wrapped only writable
function properties, and napi defines#[napi(factory)]statics as
non-writable, so the four ways to obtain a handle were the one surface with no
classification while the shipped.d.tspromised it everywhere. The exported
class is now a subclass owning classifying statics, withSymbol.hasInstance
kept pointing at the native class soinstanceofis unchanged. -
BREAKING (now refused):
@zvndev/powdb-embedded: rejected arguments say what is wrong.
Database.open("")andDatabase.openon a regular file surfaced the raw OS
error;openWithMemoryLimit(dir, NaN)opened with a silently coerced
zero-byte budget; anundefinedparameter bound a PowQLnull. All are
invalid_argumentnow. Errors from generated argument coercion, which carried
napi's own status names, are reported the same way, on every entry point. -
@zvndev/powdb-embedded: the unsupported-platform message is honest. It
blamed a known npm bug with optional dependencies, which this package does not
ship, sending readers to reinstall forever. It names the platforms that have
prebuilt binaries and carries the codeunsupported_platform. -
@zvndev/powdb-sync: the default pull byte budget matches the primary's
ceiling.maxPullUnitswas raised to the server's 4096 whilemaxPullBytes
stayed at 4 MiB against aMAX_SYNC_PULL_BYTESof 16 MiB. The byte budget is
the one that cuts a chunk, and a chunk cut inside a transaction is not
applyable, so any transaction between 4 and 16 MiB (about 1,000 rows of 5 KB)
made the primary answerrebootstrapon every pull, for good, and the replica
re-wedged on the next transaction of that size after each bootstrap. Both
budgets are also range-checked against the primary's ceilings at construction,
which previously rejected only zero and left an over-largemaxPullBytesto
fail server-side on every pull. -
@zvndev/powdb-sync: every entry point reports a transport failure as
remote_unavailable.status()andsyncNow()let the underlying client's
own code escape, so a caller branching onPowDBSyncErrorCodesaw codes
outside that union. -
The README samples in all three JS packages type-check. Each shipped one a
reader could not paste into a strict TypeScript project:queryTyped<User>
with aninterface(TS2344),.rowsoff an un-narrowedNativeQueryResult
(TS2339), and a sync adapter body whoseprimaryGenerationwas
bigint | numberagainst the addon'sbigint(TS2345). Two of the three were
the types being wrong. -
powdb-clisays which statement failed. A multi-statement--execor
script reported the error with nothing identifying the statement that raised
it; it now names the position and quotes the text. The REPL accepts one
trailing;,.schema's usage line matches.help, and the REPL banner says
"Type SQL queries" when--sqlput it in SQL mode. -
powdb-serverandpowdb-clireport a TLS certificate problem instead of a
bare handshake failure. The CLI givesCaUsedAsEndEntityits own hint, and
the generic hint no longer suggests a flag that was already passed. -
A health probe's disconnect no longer logs as a warning. A peer that
closes beforeCONNECTlogspeer disconnected before CONNECTat DEBUG and a
mid-session peer that vanishes logspeer disconnectedat DEBUG; anything
else is still WARN or ERROR. -
The nightly toolchain pin is current again, and the freshness check that is
supposed to catch it now sees every shape a pin takes. It scanned only one of
them, so theFUZZ_TOOLCHAINenv value and thecargo +nightly-...
invocations aged silently past the limit and turned the nightly fuzz run red. -
publish.yml's semver gate could not fail.cargo-semver-checks
classifies a 0.x MINOR bump as a major change and runs zero lints, so on every
ordinary release the gate passed having checked nothing. It now refuses a run
that examined no crate or ran no check on a patch bump, and reports how many
crates were linted. Separately, a 0.x minor bump could be published with no
semver verdict at all:cargo-semver-checksskips every lint on a 0.x minor
(correctly, a 0.x minor is allowed to break), so the advisory pass is that
release shape's whole verdict, and it now refusescargo-semver-checks
failing to run at all. Findings still never block. -
The "advisory, never blocks" semver step in
publish.ymlblocked, and
would have failed the next crates.io publish. An inlinerun:block runs under
GitHub's defaultbash -e {0}, andset -uo pipefaildoes not clear the
inherited-e, so the first non-zero exit fromcargo semver-checks --release-type minorkilled the shell before the log, the job summary and the
trailingexit 0. Thesemverjob went red andpublish(needs: semver)
never started. Forcing--release-type minorturns the major lints on, and
this release removes public items in published crates, so it would have fired.
The body now lives inscripts/ci/semver-advisory.shwith an explicit
set +eand a--selftest. -
ci-successcould pass with an emptyneeds:list. It is the single
required status check onmain, and its verdict wasfor result in $RESULTS
overjoin(needs.*.result, ' '), which iterates zero times on an empty list
and prints success. The job that would have caught it blocked nothing except
throughci-successitself.ci-successnow runs the completeness check
directly, againstci.ymlon disk rather than against the list it is
auditing.check-ci-success-needs.shcould also not see a job whose key line
carried a trailing comment (my-new-gate: # TODOis valid YAML and was
invisible to the parser, so it was compared against neitherci-success.needs
nor CONTRIBUTING.md while the script reported every job covered); any
unrecognised job-key shape is now an error rather than a silent drop. -
internal-content-guardreported "denylist checked" whengit grephad
errored and inspected nothing.|| trueconflated git-grep exit 1 (no
match) with exit 2 and above (an uncompilable PCRE in the denylist secret, a
git build without-P). Thegit ls-fileshalf printed "paths clean" the same
way. Both halves now read the producing command's status directly, and the
guard also skipped silently when its pattern file was missing. -
Three of the five pattern scans in
check-version-consistency.shhad no
"found nothing" floor. Pinning the deploy compose file to a digest (which the
deploy README recommends), regeneratingsite/without the leadingv, or
documenting the npm dependency as^0.27.0each silently emptied a scan and
the job still printed its ALL-PASS line. The script also now checks the
AGENTS.md release stamp: the "Available in released PowDB (vX.Y.Z)" line
drifted three minors behind and nothing noticed, and it is the line agents read
to decide which features exist. -
release.yml's npm publish skipped itself when a registry probe failed for
any reason, including a 500 or a network error, so an outage looked like a
successful release. -
publish-node-addon.ymlverified a tag that it had not necessarily built.
It resolved the version from the tag but built from whatever the checkout gave
it; it now asserts the tag resolves to the commit being built. It also asserts
the addon's lockfile is up to date and runs the package's tests on each
platform runner it publishes from. -
release-channel.shaccepted leading zeros (0.27.00,01.2.3) as valid
SemVer, which would have published a release under a version string crates.io
and npm reject. -
fuzz.ymlinstalled an unpinnedcargo-fuzzafter the commit that pinned
the third-party tools the gates run on. It now matches ci.yml's
--version 0.13.2 --locked. -
The ghcr smoke leg checked two of the three tags its own comment demanded.
The floating channel pointer (latestfor a final,rcfor a candidate) was
never pulled, so a release that moved it to the wrong digest, or never moved
it, passed the smoke. All three tags are now pulled and all three have their
org.opencontainers.image.versionlabel asserted. -
Two gates that could not fail now can. The format-version gate reads
docs/FORMAT.mdinstead of comparing the constants to a second copy of
themselves in the same file, so a bumped version with a stale table fails. The
data-directory mode gate asserts the warning it is named for instead of the
mode the code already produced without it. -
Six CLI and server tests stopped skipping themselves. They printed one line
to stderr and returned whenpowdb-serverwas not next to the CLI binary, so
the only coverage the remote client path has could vanish without changing a
test result. A missing binary is now a hard failure naming the command that
builds it. -
A crash after an ordinary insert could leave the data directory
unopenable. Two entry points place a row in a heap page and only one of them
could compact. The runtime insert compacts when a row does not fit the
untouched tail but does fit the space deleted rows left behind, so a row can be
placed purely because compaction reclaimed that space, and the identifier it
chose is what the log records. The replay entry point went straight to the slot
insert, which refuses on free space alone however much dead space the page
holds. Replay therefore could not reproduce the identifier, and opening the
catalog failed. The everyday route in is a relocating update, recorded as a
delete plus an insert: the delete makes the dead space and the insert then
needs it. Replay now compacts and retries, which preserves slot indices so
later records in the same replay stay correctly targeted. -
The automatic WAL checkpoint was off in both shipped binaries. A catalog
opened with an archive hook stopped checkpointing altogether, because the hook
was borrowed for the open and could not be run later. The server and the CLI
both always pass a hook, so--wal-checkpoint-bytesdid nothing in either of
them and the log grew without bound, which is the thing the threshold exists to
prevent. The catalog can now own a hook, and the threshold checkpoint publishes
the log through it before truncating. Nothing truncates without running the
hook, so a hook passed to the open but never registered is still a refusal to
truncate. The separate skip when PowDB's own replica sync is enabled is kept,
because there the sync machinery owns checkpointing and that is a different
reason to skip. -
BREAKING (silent, and now refused):
length()on abytescolumn answered null on every row, with no error. A
silent wrong answer. A bytes value has exactly one length, its byte count,
which is what SQLite, Postgres and MySQL all return for a blob, so it now
answers with that. An operand with no length at all (int,float,bool,
datetime,uuid,json) is now a typed refusal naming the column and its
type. Ajsondocument is refused rather than answered because it has no one
obvious length, and the->paths already spell each candidate. Both frontends
share the one code path, so PowQL and SQL agree by construction. -
A mistyped
havingnamed the planner's invented column instead of the
query's.having count(.id) = "x"reported a type mismatch on__agg_0, a
name that appears in no query, no schema and no result set. The refusal was
right; the name was not. The same internal name escaped into two neighbouring
messages, and all three now read back the aggregate the query actually wrote. -
Only one of the two spellings of an unsupported link path explained
itself. The aliased form named the limitation, which was this sprint's fix,
while the identical mistake written without an alias still reported an
unexpected trailing token. The two lex differently and landed in two different
parser arms. The wording now lives in one function both arms call. -
The node addon's own documented first line failed to load.
import { Database } from "@zvndev/powdb-embedded"raised a named-export error,
because the loader published its exports through a dynamic assignment that the
CommonJS module lexer cannot see through. The dynamic assignment stays as the
source of truth, with explicit re-publication beside it so the lexer sees the
names, and a test that reads the import line out of the README rather than
restating it. -
Requiring the addon's generated binding directly bypassed error
classification. The package had noexportsmap, so a deep import reached
the unwrapped binding and threw errors without the class the type declarations
promise. The wrapped loader is now the only reachable entry point. -
Three of the addon's runtime exports were declared in neither type file, so
they were absent from every published tarball, and the unsupported-platform
check compared a platform key against entries carrying a libc suffix, so its
membership test could never succeed on any Linux target. -
A corruption refusal at startup names the table and the remedy. A rotted
page reported onlypage 1 CRC32 mismatch, and the documented remedy is to
restore from a backup, which is a per-table decision. With forty tables in a
directory that message did not say which file to restore, and the database
would not start until one was. It now reads
page corrupt: table 'Orders' (/path/Orders.heap): page 1 CRC32 mismatch: ...; restore this table from a backup. A catalog file that will not parse names
itself and its remedy the same way. The typed error class is preserved, so
what reaches the wire is unchanged. -
A Unix-domain socket path close to the platform limit binds again. The
socket is bound under a private staging name and renamed into place, so it is
never briefly world-connectable. That staging name was built by decorating the
published one and cost 15 bytes, andsun_pathis a hard 104 bytes on macOS
and 108 on Linux, so any socket path within 15 bytes of the limit stopped
binding: on macOS the per-user temp directory is 49 bytes by itself. The
staging name no longer grows with the published one, and a bind that still
fails on length now names both paths and the difference instead of reporting a
system error about a path the operator never wrote. -
A conjunction with a hot indexed literal no longer pays for counting it.
The index chooser ranks a conjunction's indexed equalities by the exact count
of each literal, and that count is bounded at half the index. The bound was
walked in full on every execution when the literal was hot:filter .is_published = true and .data->slug = "s2"over 20K rows, with both an index
on the 50/50 boolean and one on the per-row-unique path, walked ~10K index
entries per query to learn what the path's count of 1 had already decided,
and the query took twenty times longer than the same query without the
boolean index. The plan it chose was right all along; only the choosing was
slow. Counting now starts at a small cap and doubles only until the ranking is
settled, which picks the same driver the full count picks in every case (a
saturated tie goes another round; an all-hot tie still falls to conjunct
order), and a unique equality or a lone one is never counted at all. The
regression dated from 0.19.1 and went unmeasured because the workload sat in
the benchmark baseline but not in the comparator's gated list until now. On
the same Depot instance, head against 0.27.0: 65,612 ns to 4,808 ns (-93%). -
A query no longer rebuilds its validation scaffolding on every execution.
Since 0.20.0 every execution, plan-cache hits included, checks the plan
against the catalog as it is right now: unknown tables and columns, mistyped
comparisons, mistyped JSON path bases, negative slice counts and stray
aggregates. Those checks stay where they are, because a column dropped between
two executions of a cached plan must still be refused on the second one (a
test now holds that on both the mutable and the read-only path). What changes
is their cost. Each check used to gather the scan columns for itself, three
walks plus a fourth for the join ambiguity check, cloning every column name
into a fresh string and building hash sets of them per query. They now share
one walk and borrow every name they resolve, the projection names that can
shadow a JSON path base are collected only when the plan carries a->path,
and the join ambiguity walk is skipped for a single-table plan. On the same
Depot instance, head against 0.27.0: a point lookup 1,977 ns to 1,439 ns
(-27%), an update by primary key 2,018 ns to 1,616 ns (-20%).
Security
-
The auth rate limiter is bounded in key size and entry count. A failed
CONNECTused to retain the whole username the peer sent as a map key for a
60-second window, and nothing under the 4 KB pre-auth frame limit bounded that
name. A key now retains at most 64 bytes of username, the table holds at most
4096 buckets, and it evicts deterministically at capacity (lowest failure
count, then oldest window, then key order), so a spray of throwaway source
addresses cannot clear a peer that is close to its bound. The expiry sweep runs
at most once a second instead of on every handshake, so a legitimateCONNECT
no longer pays an O(n) scan of a table an attacker inflated. -
BREAKING (operational): A shared-password deployment counts auth failures against the peer, not the
username. A server with no user store never reads the username while
authenticating, so counting failures against it let one address make 50
password guesses a minute instead of 5. It also made an unauthenticated peer
the author of the limiter's keys for exactly the deployment shape that has no
user store. Relatedly, a Unix-socket peer has noSocketAddrand was exempt
from rate limiting entirely; all socket peers now share one bucket. -
A wrong username no longer locks another user out. Failures are counted per
(peer, username) at 5 per 60 seconds and per peer at 50 per 60 seconds, so a
username sweep from one address is still stopped without letting it lock a real
user out of their own account from behind a shared NAT. -
The Unix-domain socket is never connectable at the process umask.
bind
created the node at 0755 or 0775 and thechmodto 0660 ran afterwards, and a
chmodon a socket does not close connections already accepted on it. The
socket is now bound at a private staging name in the same directory
(.<name>.staging-<pid>), restricted there, and renamed into place, so the
published path only ever names an already-restricted socket and is never
briefly absent either. -
BREAKING (now refused): A data directory the owner cannot open is refused rather than widened. A
0555, 0500 or 0000 data directory was silentlychmodded to 0700 and written
into. The change is now logged with both the old and the new mode, and 0000 is
refused. -
drop linkis classified as schema definition under RBAC, alongside
create link, so areadonlyprincipal is refused with the message the rest
of DDL gives. No outcome changed for any builtin role.
What's Changed
- Full review of v0.27.0: the 124-item audit, the bug hunt on its fixes, and the rigor review on those by @zvndev in #231
- chore: release v0.28.0 by @zvndev in #243
Full Changelog: v0.27.0...v0.28.0