Skip to content

fix: row editing wrote to the wrong row, and eight other defects measured on live engines - #967

Closed
yusuf-gundogdu wants to merge 18 commits into
mainfrom
fix/row-editing-schema-diff-and-providers
Closed

yusuf-gundogdu wants to merge 18 commits into
mainfrom
fix/row-editing-schema-diff-and-providers

Conversation

@yusuf-gundogdu

Copy link
Copy Markdown
Member

Every defect here was reproduced on a running engine before it was changed, and each fix is held by a test that fails without it.

The row editor wrote to the wrong row

Three drivers handed integers past 2^53 to JavaScript rounded, so two rows whose ids differ in the last digit arrived in the browser as the same value. The inline editor's key guard asked the engine about the rounded id, got one row back, and sent the UPDATE to the neighbour. Measured on MySQL 8.4.11 and on both SQLite drivers; node:sqlite raised ERR_OUT_OF_RANGE instead, which is the same defect wearing a louder spelling.

MySQL now sets supportBigNumbers. SQLite and libsql convert at the provider boundary: a value that fits comes back a number, one that does not comes back its decimal string, and no BigInt leaves a provider. The bind side is the exact inverse, so only a digit string the read side could itself have produced goes back as a 64-bit integer, and a leading zero, a leading plus, a trailing .0, exponent form and anything wider than 64 bits all stay text.

Six refusal messages were untrue of what fired them

The editor refuses an edit it cannot address safely, and six of its sentences said something false about the value in front of the user. A binary key was told its rows were gone from the table while they were still there, with advice that looped forever. A date-time key was fixed only on the path the browser does not use. A fractional key was refused as unreachable when the engine matched it exactly. A large float was refused as an out-of-range integer. A grouped-count answer reported half of what it knew.

The decisions now read the declared column type carried with the result, and the engine as well as the type name, because real is 32 bits on PostgreSQL and 64 on SQLite. Each engine was measured rather than assumed; the ones that could not be reached keep the refusal.

The schema diff panel

Eight defects: a snapshot failure that cleared itself silently, a read that wrote after the panel was unmounted, a double-Enter guard nothing measured, no way to re-read without leaving the tab, Date.now() snapshot ids that collided so deleting one deleted two, a missing snapshot drawn as an empty database so the panel reported every table removed, a busy indicator a superseded read could clear, and a remote fetch that could take the target back after the user had chosen something else. A failed remote fetch now says so instead of going quiet.

Published credentials

Eleven working credentials were removed from the documentation, and the example env file's encryption key is now below the minimum so uncommenting it stops the server rather than sealing saved passwords with a published key. A guard fails the build on the next one: measured against a credential injected into six real files, and silent on six innocent rows of the real chart table.

Provider documents

docs/providers/{mysql,sqlite,libsql}.md are updated in the same PR, as docs/providers/README.md requires, with the measurements taken for those documents rather than copied.

Checks

typecheck, format, lint, knip, build, build:lib, attw, chart:check, readme:check, security:check, channels:showcase:check, distribution:check all pass. 569 test files, 18511 passing, and line coverage is 100 percent at 59114 of 59114.

Three independent reviewers read earlier states of this branch and rejected it three times, for a red coverage gate, a documentation regression, two commit messages asserting measurements that did not hold, and four shapes of credential the guard let through. Those are the reasons the branch looks the way it does.

… one row

The key an inline edit builds its WHERE on is a guess: the first field called id
or ending in _id. On a result that carries a foreign key rather than the table's
own key - SELECT category_id, product_name FROM products - the guess lands on
category_id, and the UPDATE rewrites every product in that category. Measured on
PostgreSQL 16: one cell edited, fifteen rows changed, one statement reported as
accepted.

Two questions now stand between an edit and a write, and both had to be asked.

IS IT THE TABLE'S COLUMN. A name is not a provenance. SELECT ROW_NUMBER() OVER
(ORDER BY product_name) AS product_id, product_name FROM products puts 1, 2, 3
in a field called product_id; products really has a product_id; and the UPDATEs
land on whichever products those are, not on the rows on screen. Measured, two
cells edited, two rows written, neither of them visible, both reported accepted.
SELECT sku AS product_id is the same thing spelled shorter. selectsPlainColumn
reads the select list and requires the field to be the column itself - a star, or
a reference whose alias, if any, names what it already names.

DOES IT ADDRESS ONE ROW PER VALUE. A grouped count over the distinct keys, bound
rather than interpolated: as many distinct keys as rows on screen, as many groups
back as distinct keys, every group holding one row. Each clause replaced a version
measured letting a write through. Counting per row sent IN (87, 87, 87) for three
rows sharing order_id 87 and wrote all three to all three. An ungrouped total
passed abc and ABC on MySQL's case-insensitive collation, where WHERE k = 'abc'
writes to both. String() alone collapsed SQLite's text '1' and integer 1, and two
MySQL BIGINTs past 2^53 that mysql2 rounds to the same number.

The check goes to /api/db/transaction when a transaction is open, because that is
where the UPDATEs go: asked on a pooled connection it cannot see a row the
transaction has not committed, and the apply refuses for ever with a sentence that
is false about a row the user is looking at. It asks for one more row than there
are keys, because the default page is 500 and groups that fell off would read as
rows that are gone.

A key that cannot be read as text at all - a value with a null prototype - is
refused before anything is built, where it used to throw as the statement was
assembled and take the apply down with no write and no message. A null key is
refused too. Fewer rows than keys gets its own sentence, because it says nothing
about whether the column tells them apart. A check that could not be run is not a
check that passed, and the pending edits are kept in every case.
… a diff is asked for

#884 moved Current Schema off the explorer's cached copy and onto a read of the
connection, but that read sits in an effect keyed on [connection] alone, so it
happens once and not again while the panel stays open. The sequence the Diff tab
exists for - snapshot, change the database, compare - still answered "No
differences found". Measured against PostgreSQL 16 with the panel left open.

Two moments read the connection now, and between them they are the sequence: a
snapshot reads it, and choosing a target reads it again, because that is when a
person asks to be told the difference. Freshening only the snapshot was tried
first and is not enough - the other side stays at the moment of the snapshot.
Two snapshots compared against each other read nothing: neither side is the
database.

Three reads can be in flight at once, so they are sequenced through
useReadGeneration, which the repository already states once for this problem. It
replaces comparing the connection OBJECT, which is not safe: activeConnection is
a useMemo over a prop the embedded host supplies, so a host handing over a fresh
array per render produces a fresh object per render and a read would be discarded
on a connection that never changed.

The rest is what an awaited read in a click handler needs and did not have: a
re-entrancy guard in a ref, because two Enter presses land in the same tick;
setSnapshotting(false) in a finally, or a superseded read leaves the button
reading Reading... for the life of the panel; the label input and Cancel disabled
while the read is out; the storage write inside the try, because a localStorage
quota refusal is an ordinary outcome for a whole schema; and a snapshot that was
overtaken saying so rather than returning quietly, which saved nothing and said
nothing while the button went back to Save.

The failure banner carries the connection it was about, so a failure on a database
the user has left does not sit over the one they are looking at, and a later read
of that database that works clears it.
Eleven of them, all copy-and-run, and two guards so the twelfth cannot get in
quietly.

The Koyeb deploy button prefilled ADMIN_PASSWORD and USER_PASSWORD with
set_a_real_password. That reads as a placeholder and works as a password:
measured against a running container, the standard user account signed in with
it. There is no placeholder that fixes it, so the button carries neither, and
USER_EMAIL goes with them. Unset, the two behave differently and both answers are
safe: ADMIN_PASSWORD is generated on first run and printed to the log, while
USER_PASSWORD is never generated - getAuthUsers adds that account only when it is
set, so without it there is no second account at all.

.env.example was the worst of them, because README says to copy it and run it:
ADMIN_PASSWORD, USER_PASSWORD and a 36-character JWT_SECRET, which clears the
32-character minimum and is also what saved connection passwords are sealed with
when STORAGE_ENCRYPTION_KEY is unset. README's own docker run block had
LibreDB.2026 twice with the same string underneath as the login to use, and the
same 36-character secret, which was in docs/DISTRIBUTION.md twice and in
packaging/linux/env, installed to /etc/libredb-studio/env by every .deb and .rpm.
DOCKERHUB.md is the Docker Hub landing page and carried a password through both
its examples. docs/SEED_CONNECTIONS.md carried MyAdmin123 and MyUser123 through
both of its. README's env block, docs/MFA.md, docs/DISTRIBUTION.md and
CONTRIBUTING.md carried your_secure_admin_password and admin123, which are the
same thing wearing politer names.

All of them are empty now rather than replaced, and each block says where the
generated password is printed. The one secret that remains is under the minimum,
so a deployment left as it stands stops at boot and says why.

Two guards, because three of these were removed by hand this week and nothing
stopped the next: published-credentials.test.ts fails on any ADMIN_PASSWORD or
USER_PASSWORD assignment and any JWT_SECRET of 32 characters or more across
README.md, DOCKERHUB.md, docs/ and deploy/; and the Koyeb test now parses every
Koyeb URL in the file with URL rather than reading the first matching line with a
regular expression over one spelling. It caught one of four ways of putting a
password back before that and catches all four now.

Not touched: --set secrets.adminPassword=MyAdmin123 in twenty-one places of Helm
documentation. The chart's values are empty and the template requires them, so
nothing is carried into a deployment - the reader types it and can see they are
typing a password.
… row

mysql2 hands any integer beyond 2^53 to JavaScript as a rounded number. Two rows
whose ids differ in the last digit therefore arrive in the browser as the same
value, and the inline editor's key guard is satisfied by that: it asks the engine
about the rounded id, gets one row back, and sends the UPDATE. Measured against
MySQL 8.4.11 with the real provider: the statement landed on the neighbouring
row.

buildPoolConfig now sets supportBigNumbers on the base config, so it covers both
the form-fields path and a pasted connection string. Only values mysql2 judges
too large come back as strings, and its threshold is anything above
Number.MAX_SAFE_INTEGER, so 2^53 exactly is a string too even though its value
was never in doubt. Everything narrower is untouched, measured one at a time: a
small INT, COUNT(*), SUM over small values, a BIGINT holding a small value, an
AUTO_INCREMENT id, DECIMAL, and 2^53 - 1.

One other shape changed, and it was already broken: an UNSIGNED BIGINT at the top
of its range read back as 18446744073709552000, which is not the stored value. It
is now the digits the row holds.

bigNumberStrings was deliberately not set: it would turn every BIGINT into a
string, including the small ones.
The same hole as MySQL, and worse on the driver the Docker image runs. Measured
with the real provider on both: bun:sqlite silently rounded 9007199254740993 to
...992 and the inline editor then updated the neighbouring row, while node:sqlite
threw ERR_OUT_OF_RANGE and refused to read the row at all. Two spellings of the
same defect, one of them silent.

Turning on the drivers' big-integer flag alone is not the fix. Measured: every
integer becomes a BigInt, including 1 and COUNT(*), rows are sent to the browser
as JSON, JSON.stringify refuses BigInt, and the connection stops opening. 180
passing became 149 passing with 31 failures, 16 of them the database failing to
open.

So the flag is on for both drivers, spelled differently for each, and the
conversion happens at the provider boundary: a value that fits comes back as a
number, a value that does not comes back as its decimal string, and no BigInt
leaves the provider. The boundary is Number.MAX_SAFE_INTEGER, which is what the
libsql driver already uses. Small integers are unchanged, including COUNT(*),
rowid, length(), CAST and PRAGMA columns, measured one at a time.

Reading correctly then opened the other half. A column with no declared type, or
declared BLOB, has no affinity, and SQLite never compares a string equal to an
integer, so sending that id back matched nothing and the user was told no rows
changed. toSQLiteBindValue makes the bind side the exact inverse of the read
side: only a digit string the read side could itself have produced goes back as a
64-bit integer. A leading zero, a leading plus, whitespace, a trailing .0,
exponent form, the empty string, anything inside the safe range and anything
wider than 64 bits all stay text, so a genuinely textual key still behaves as
text.
The read side was already right here: decodeInteger returns a value outside the
safe range as a string, and the transport's own type admits it. The send side was
not. encodeValue passed that string through as text, and a column with no
affinity never compares text equal to an integer, so the row could be read and
then not updated.

Measured against sqld 0.24.33 in a container, with the real provider: reading
9007199254740993 and sending it back changed one row in an INTEGER column and one
in a TEXT column, and zero rows in a BLOB column and in a column with no
declaration at all. The user was told nothing had changed.

encodeValue now mirrors decodeInteger through isDecodedInteger: only a digit
string the read side could itself have produced is sent as an integer. Measured
on the live server, a leading zero, a leading plus, whitespace, a trailing .0,
exponent form, the empty string, a value inside the safe range and one wider than
64 bits all stay text, and so does a genuine text key that happens to be digits.
…e guard

.env.example carried STORAGE_ENCRYPTION_KEY as a commented line reading
your_32_character_random_string_here. It looks like a placeholder and it is
thirty-six characters, which clears the thirty-two-character minimum, so it works.
A copy left untouched is not affected, because the line is commented out. What the
file invites is uncommenting it, which is what a commented example is for, and the
reader who does that seals every saved connection password with a key published in
this repository.

It now reads too-short-generate-your-own, twenty-seven characters. With
server-side storage on - STORAGE_PROVIDER set to sqlite or postgres - uncommenting
it as it stands stops the server at startup and says the key is too short. The
file ships STORAGE_PROVIDER=local, where the key is never read, so on the settings
it ships with nothing changes either way. The comment above the line states that
condition and gives the command to generate a real key.

The guard that was added with the credentials removal only looked at README.md,
DOCKERHUB.md, docs/ and deploy/. Measured against nine ways of putting a
credential back, it missed most of them. It now also reads render.yaml, the root
Dockerfile and .github/workflows, accepts .txt as well, catches the camelCase
adminPassword and userPassword spellings, catches STORAGE_ENCRYPTION_KEY, catches
a value followed by another flag or a comment on the same line, and catches a key
and value split across two lines. All nine attempts are caught and ten innocent
shapes stay silent.
…y edited

Every refusal the inline editor can show was read against the situation that
fires it. Four of them were saying something that was not true of what was in
front of the user, and all four were measured live on PostgreSQL 16.15 and MySQL
8.4.11 through the real provider.

A key holding binary data got "some of the rows you edited are no longer in the
table. Run the query again." The rows were still there, and running the query
again produced the same unusable key, so the advice looped forever. A bytea, a
BINARY(16) and the {type:"Buffer"} shape a driver puts on the wire are now
refused before the engine is asked at all, with a sentence about the value
itself.

A date-time key was fixed only on the path the browser does not use. Over
/api/db/query a Date becomes an ISO string, the check saw a string and let it
through, and the old false sentence came back with count(*) sitting at 2. The
check now reads the column type the result already carries rather than the look
of the value, so a column that genuinely holds the text of a date still works,
proven by editing one end to end.

A fractional key was refused as a value that "does not reach the table as the row
holds it". Measured: 0.30000000000000004 in a double precision column reached the
table exactly, and WHERE found the row. It is now written, and what is still
refused is refused with a sentence that is true of it.

A large-magnitude float was refused as an out-of-range integer. 1e21 sits exactly
in a 64-bit float and both engines matched the row. The out-of-range refusal is
right for an integer column, where past 2^53 the browser holds a different value
than the row does, so the decision is now made on the declared column type rather
than the size of the number.

One refusal was also half-blind: when the engine answered for fewer keys than
were sent it said only that the key does not tell the rows apart, and dropped the
more important half. It now says both, that a row may have gone since the read
and that the engine may fold two keys into one, without asserting either.
Eight defects in the comparison panel, each measured before it was changed and
each pinned by a test that fails without the fix.

A snapshot whose read failed used to clear its own warning on the next render, so
the user saw the panel go quiet and had no way to know nothing had been saved.
The warning now stays until it is dismissed, and there is a Dismiss button to do
it with.

Leaving the Diff tab unmounts the panel, and a read still in flight went on to
write its snapshot afterwards. The cleanup supersedes both counters now, and four
state writes that ran after an await are guarded, so nothing the panel started
touches storage or its own state once it is gone.

Pressing Enter twice was guarded but the guard was measured by nothing: removing
it left every test green. Without it the database is read twice, one snapshot is
saved, and a "Press Save again" banner appears that the user did nothing to earn.
Both halves are now tested.

Choosing the target that was already chosen re-rendered nothing and so re-read
nothing, and there was no refresh control anywhere, which left leaving the tab and
coming back as the only way to see current data. There is a Refresh button, its
read goes through the same generation counter, and two quick clicks start one
read rather than two.

Snapshot ids came from Date.now(), so two taken in the same millisecond shared an
id: React warned about duplicate keys and deleting one deleted both. Worse, the
store keeps fifty, and when a selected snapshot fell off the end the lookup
returned nothing and an empty array was handed to the diff, which then reported
that every table in the database had been removed. Ids come from the repository's
own generator now, and a snapshot that cannot be found says so instead of being
drawn as an empty database.

The remote fetch was sequenced for its writes but not for the busy indicator: a
superseded read could take "Fetching..." down while a newer one was still
outstanding. And choosing a stored snapshot did not supersede a remote read at
all, so a fetch the user had abandoned could land afterwards and take the target
back, with the indicator left hanging. Both orderings now end with the panel
showing the thing the user chose last.
…ched

A failed remote fetch said nothing at all. "Fetching..." disappeared, the target
stayed where it was, and the error went to the log. The comparison on screen was
still the old one, and nothing told the reader it was not the database they had
just asked for.

The failure now appears in its own strip, built the same way as the snapshot
failure strip and dismissed the same way. It keeps its own state rather than
sharing the snapshot one, because that strip is tied to the connection on screen
while this is about a different database, and pressing Save should not clear it.

Only the fetch the user is waiting on can raise it: a superseded fetch that fails
late stays silent, which is checked by the same generation counter that already
guards the writes. The guard on the mount-time read was measured before this
change and caught by nothing.
An earlier commit on this branch removed SQL Server from the list of engines
agent mode reads. That was wrong and it was unrelated to what the commit was for.
mssql.ts implements queryReadOnly, and engine-support.ts lists four engines, not
three, which is what the engine-support test reads from the real providers.

It was also applied unevenly, so the documents contradicted each other: README,
DOCKERHUB and FEATURES said three engines while AGENT_GUIDE and the Chinese,
Japanese and Hindi READMEs still said four. FEATURES went further and listed
three engines in one sentence and then called them "those two engines".

The English text is restored and the sentence that counted them is right again.
The four-layer read-only profile SQL Server needs, which has no read-only
transaction of its own, is described where the other three engines' profiles are.

README also carried four source references with line numbers that do not hold
those symbols - postgres.ts:915, sqlite.ts:537, duckdb/index.ts:525 and
runtime.ts:199. The line numbers are gone rather than corrected, because they go
stale on the next edit and the file name alone is enough to find the symbol.

The Spanish and Urdu READMEs never carried this claim, so nothing changed there.
The coverage gate was red at 59092 of 59094 lines. The two uncovered lines were
the scalar-bigint and typed-array branches of the driver's record normalizer,
both added by the 64-bit integer commit on this branch, and the repository
requires every line.

The seam they sit on is typed unknown deliberately: the statement interface
declares all() as unknown[] and get() as unknown, so what arrives is whatever the
injected driver returns. Measured against both shipped drivers, for all(), get(),
run(), a miss and a PRAGMA read, every answer is a row object, a null miss or
run()'s info object, and a BLOB is a cell inside a row rather than the record
itself. So neither branch is on a path those two take today. They are what stops
a driver that answers with a bare cell - a values mode, or the future
row-returning method the module's own comment warns about - from handing a BigInt
out of the provider or walking a typed array cell by cell.

They are driven through the injectable constructor the module already exposes for
exactly this, so no driver is mocked and no source changed. Each test states what
it kills: removing the scalar branch sends a BigInt through JSON.stringify, which
refuses it outright, and removing the typed-array branch writes converted cells
back into a typed array that will not hold them.
…e four holes in the guard

A reviewer put working passwords into this repository four ways and the guard that
is supposed to stop that stayed green each time. Every one is now caught, with the
nearest innocent shape written beside it so the next person it stops does not
delete it.

A Markdown table row was the worst of them, because a variable table is how these
files list their settings: the name is one cell and the value is the next, and
nothing between them is an assignment. Only a value cell written as a code span
with no space in it counts, which is what tells the value column from the
description column beside it.

The other three were one rule being too strict. `NAME=value` had to be followed by
a comment, a pipe, another flag or the end of the line, so `docker run -e
ADMIN_PASSWORD=Secret123 imagename` and `export ADMIN_PASSWORD=Secret123 && echo`
both read as prose. A sentence does not write an equals sign, so an `=` no longer
asks what follows it; `NAME: value` still does, which is what keeps
`ADMIN_PASSWORD: generated on first run` a sentence. And a quoted value was read
to the first space, so a password with a space in it looked like one word followed
by prose - the same hole hid a secret at full length, and a secret is judged by
its length.

One value had to be excused: deploy/azure/src/install.sh writes `printf
'ADMIN_PASSWORD=%s\n'` and pours the password in from a variable. A run made
entirely of format specifiers is the hole, not the value.

docs/API_DOCS.md was still handing readers admin123 in two login examples, a cURL
block and a fetch block, which is the same string removed from CONTRIBUTING.md by
hand earlier. They now carry a placeholder and a line saying the real password is
generated on first run and printed to the log. The guard reads login bodies now,
found by the `email` key beside the password. Connection bodies are deliberately
left alone: the password in one of those is the reader's own database, sampled as
postgres or password123, and nothing here is reachable with it.
… its integer column

Every other provider sends the declared column types alongside the rows. The local
SQLite provider never did, and until this branch nothing noticed: the exporter
falls back to the JavaScript type of the value, and a SQLite integer used to
arrive as a number.

The 64-bit fix on this branch changed that. An id past 2^53 now arrives as a
string, so a table carrying one exported as `"id" TEXT` with the value in quotes,
and a user moving that table to another engine got a text column where they had an
integer one. Measured: exported, restored, and `typeof(id)` came back `text`.
libsql was unaffected the whole time, because it reports its declarations.

This follows the libsql path exactly, down to omitting the key when the map is
empty rather than sending `{}`. The two drivers spell the declarations
differently, so one read-only accessor is bridged in sqlite-driver.ts the way
`safeIntegers` and `inTransaction` already are, and nothing new reaches a shared
surface.

It is read after the rows, not before, because bun's `declaredTypes` raises until
the statement has run. bun also publishes `columnTypes`, which is not this: it is
the runtime class of the value, so a REAL column reads FLOAT and an undeclared one
reads INTEGER, and it raises on `PRAGMA journal_mode`. Measured on Bun 1.4.0 and
not used.

An absent declaration stays absent. An expression, a literal, an aggregate, a
function call, a PRAGMA column and a column declared with no type all get no
entry, because two decisions on this branch now read this map and a guess in it
would be worse than nothing.

Not fixed here: a SQLite REAL key is still refused by the row editor, which asks
whether the column is a 64-bit float and does not count `real` because on
PostgreSQL and Trino it is 32 bits. On SQLite it is 64. That answer belongs in the
editor, with the engine in hand.
…there

The editor asks whether a fractional key's column is a 64-bit float, because a
value read at a different width than the row holds can miss the row or write to
the wrong one. `real` and `float` were left out of the safe list, and that was
right for the engines it was written against.

SQLite has no 32-bit float. REAL is the only float width it has, and FLOAT, DOUBLE
and DOUBLE PRECISION are accepted spellings of the same storage class. Measured
live: all four report the same decltype, `typeof()` answers `real` for each,
0.30000000000000004 reads back exactly, and `WHERE k_id = 1.5` matched one row
while the editor was refusing that same edit. The sentence it gave - that nothing
said the column was a 64-bit float - was false, because the declaration said so.
This only became visible now that the SQLite provider reports its declarations.

So the decision reads the engine as well as the name, from the connection the
caller already holds. Nothing new is threaded through the module.

Each engine was measured rather than assumed. PostgreSQL `real` and `float4` are
four bytes by `pg_column_size` and `0.1::real::float8` is 0.10000000149011612, so
they stay refused. MySQL FLOAT compared false against 0.1 in the same row where
DOUBLE compared true, so FLOAT stays refused. DuckDB hands 32-bit values over as
0.10000000149011612, so REAL and FLOAT stay refused. libsql is SQLite and behaves
as SQLite, checked against a running server.

Trino REAL and SQL Server `float` could not be reached from here, so both keep the
refusal: an unmeasured engine gets the closed side, which is what this rule chose
in the first place.
… body as it is written

An adversarial reviewer broke this guard three ways and proved each by reproduction.

It read only the cell immediately after the name, and only a backticked span. The
real tables here are shaped Variable, Required, Description, so the value lives in
the third cell, written inside the description as "(default: LibreDB.2026)". Seven
shapes of that got past, including the plain and bolded spellings.

Reading more cells is what made the second finding possible: docs/HELM_CHART.md
has a Source column whose cells are chart value paths, so a row there reported
secrets.adminPassword as a published password, and
secrets.jwtSecret.fromExistingSecret is thirty-seven characters, which would have
failed the secret-length test outright. A guard that fires on innocent text is the
one that gets deleted by the next person it stops.

So position is not what decides a cell any more: the table's own header does. A
column headed Value, Default or Example holds values; a "default: x" written in
words is an assignment wherever it appears; a two-column table's single marked
cell is a value; with no header at all the cell after the name is read, as before.
A value that spells the variable's own name back - secrets.adminPassword,
admin-password - is a reference, not a value.

The third was the JSON rule requiring double quotes around the email key. The
file it was written for does not use them: docs/API_DOCS.md wrote
`{ email: 'admin@libredb.org', password: 'admin123' }`, a bare key and single
quotes, and that one escaped while its sibling was caught. The test that claimed
to cover "the two shapes in the file" had hand-converted this one to double
quotes - a line that never existed there. It now uses the real shape.

Measured rather than argued: a credential line injected into README.md,
DOCKERHUB.md, docs/API_DOCS.md, deploy/railway, deploy/koyeb and docs/HELM_CHART
is caught in all six, and six innocent rows added to the real HELM_CHART table
stay silent in all six. Eight tests became ten and none of the eight was weakened.
… now does

docs/providers/README.md says the code, the document and the provider's
integration test move together in the same pull request. This branch changed three
providers and left their documents behind, and one of them now said something
false.

mysql.md said a BIGINT written as 9007199254740993 comes back as
9007199254740992. That was true until supportBigNumbers was set. Measured again on
MySQL 8.4.11 with mysql2 3.24.4, both protocols: without the flag both rows read
back 9007199254740992 and the unsigned ceiling read 18446744073709552000; with it
they are the digits the rows hold. INT, COUNT(*), AUTO_INCREMENT and 2^53 - 1 are
still numbers, and 2^53 exactly is a string, because mysql2's threshold sits above
the safe range.

sqlite.md had nothing about either change, and one of them is a new capability.
The document now carries the 64-bit integer work in both directions - bun:sqlite
rounded silently while node:sqlite raised, the boundary is the safe-integer limit,
and a text bind matched no row in a BLOB or untyped column where an integer bind
matches one - and the declared column types this provider never used to report,
including why they are read after the rows rather than before and why bun's
columnTypes is not the same thing as its declaredTypes.

libsql.md described only the reading half of section 3.4. The send side is
measured against a live sqld 0.24.33 and written beside it: the round trip changes
one row in all four column types now, where a text bind used to change none in a
BLOB or untyped column.

Every claim was measured for this document rather than copied from the commit that
made the change. BACKLOG's count of the drivers that fill columnTypes is updated
with it, since SQLite is now one of them.
The guard that stops a working password being published has to contain
password-shaped strings, because that is its subject. It carried six that read
like real ones, and a secret scanner on the pull request reported all six.

They are renamed to values that say what they are - example-not-a-real-password,
example-fake-login and the rest - and each was measured afterwards to confirm the
guard still reads it as a value rather than as a stand-in. That distinction is the
whole risk in this change: the guard deliberately ignores a leading dollar sign,
braces, angle brackets and a `your-` prefix, so a fixture that drifted into one of
those shapes would leave the test green while measuring nothing. Turning one
fixture into a stand-in was tried and drove a test red, which is what the renaming
had to preserve.

The two length-dependent assertions are untouched and still exact at forty and
thirty-two characters, because a secret is judged by its length.

Two sentences elsewhere spelled a removed password out while explaining that a
value reading as a placeholder still works as one - one in the Koyeb deploy notes
and one in its test. The sentences are gone; the paragraph around them and the
assertion they described are unchanged.

Ten tests before, ten after, ninety-nine assertions in both.
@gitguardian

gitguardian Bot commented Sep 18, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 2 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36754985 Triggered Generic Password 3d000a4 tests/unit/published-credentials.test.ts View secret
36754985 Triggered Generic Password 917a257 tests/unit/published-credentials.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sonarqubecloud

Copy link
Copy Markdown

@yusuf-gundogdu

Copy link
Copy Markdown
Member Author

Superseded by #968, which carries the same content with the credential-shaped test fixtures renamed in every commit rather than only the last.

@yusuf-gundogdu
yusuf-gundogdu deleted the fix/row-editing-schema-diff-and-providers branch September 18, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant