diff --git a/.env.example b/.env.example index b90c0e148..f16207b6a 100644 --- a/.env.example +++ b/.env.example @@ -52,13 +52,18 @@ # required only when AUTH_BOOTSTRAP=off; otherwise it is auto-generated on # first start (see ZERO-CONFIG BOOTSTRAP section below). ADMIN_EMAIL=admin@libredb.org -ADMIN_PASSWORD=your_secure_admin_password +# Left empty on purpose: a value here is a password anyone reading this file knows, and +# this file is copied to .env and run as it stands. Unset, one is generated on first start +# and printed to the log once. Fill it in only to choose your own. +ADMIN_PASSWORD= # User credentials (query execution only) — OPTIONAL. # The lower-privilege user account exists only when USER_PASSWORD is set. # Leave USER_PASSWORD unset to run admin-only (no default user password is ever assumed). USER_EMAIL=user@libredb.org -USER_PASSWORD=your_secure_user_password +# Empty means the account does not exist at all - it is never generated. That is the safer +# default for anything reachable from outside. +USER_PASSWORD= # TWO-FACTOR AUTHENTICATION (TOTP) — optional, local provider only # ============================================ @@ -91,7 +96,11 @@ USER_PASSWORD=your_secure_user_password # AUTH_BOOTSTRAP=off, which turns that generation off too: unset in production with # bootstrap off, the server stops at startup for the same reason, because nothing would # produce a secret and every login would be 503. -JWT_SECRET=your_32_character_random_string_here +# Empty on purpose. A placeholder long enough to clear the 32-character minimum is a +# working secret published in this file, and with STORAGE_ENCRYPTION_KEY unset it is also +# what saved connection passwords are sealed with. Generate one: +# openssl rand -base64 32 +JWT_SECRET= # ============================================ # ZERO-CONFIG BOOTSTRAP (Optional) @@ -190,7 +199,13 @@ STORAGE_PROVIDER=local # you re-enter the password once. Restore the previous key BEFORE the app writes again if you # want the old values back. # Browser localStorage is NOT encrypted; this variable does not change that. -# STORAGE_ENCRYPTION_KEY=your_32_character_random_string_here +# Short on purpose. A placeholder long enough to clear the 32-character minimum is a +# working key published in this file, and it is what the passwords inside saved +# connections are sealed with. Uncomment the line as it stands and, with server-side +# storage on, the server stops at startup (exit code 1) and says the key is too short - +# which is the point: nothing comes up on a key anyone can read here. Generate your own: +# openssl rand -base64 32 +# STORAGE_ENCRYPTION_KEY=too-short-generate-your-own # =========================================== # SQLite DB Provider Driver (advanced) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80b950b8e..49ce2cff7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -285,9 +285,9 @@ the password once to the dev-server output. Set them to pin known values instead (`USER_PASSWORD` additionally creates the optional non-admin account, which is never generated): ```env -ADMIN_PASSWORD=admin123 -USER_PASSWORD=user123 -JWT_SECRET=your_32_character_random_string_here +ADMIN_PASSWORD= +USER_PASSWORD= +JWT_SECRET= ``` Optional (for AI features): diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 193e6db74..4a445e046 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -31,14 +31,10 @@ docker run \ --name libredb-studio \ -p 3000:3000 \ -e ADMIN_EMAIL=admin@libredb.org \ - -e ADMIN_PASSWORD=change-me-admin \ - -e USER_EMAIL=user@libredb.org \ - -e USER_PASSWORD=change-me-user \ - -e JWT_SECRET=change-me-to-a-random-32-char-string \ libredb/libredb-studio:latest ``` -Open and log in with the `ADMIN_EMAIL` / `ADMIN_PASSWORD` you set above. **Use your own strong passwords and a random `JWT_SECRET`** — the values here are placeholders. +Open . No password is set above, so the first start generates one and prints it with `docker logs libredb-studio`. To choose your own instead, add `-e ADMIN_PASSWORD=...` and `-e JWT_SECRET=...` — the secret has to be at least 32 characters, and a value short enough to read as a placeholder is what stops a container coming up on a published one. `USER_EMAIL` / `USER_PASSWORD` are optional and create a second, lower-privilege account; without them there is no such account. > **None of these auth variables are mandatory.** With the local provider, `ADMIN_PASSWORD` and `JWT_SECRET` are required only when you opt into strict mode (`AUTH_BOOTSTRAP=off`); otherwise both are generated on first start and the admin password is printed once to the container log. `USER_EMAIL` / `USER_PASSWORD` are always optional — omit them to run admin-only, since no default user password is ever assumed. None of them are used when `NEXT_PUBLIC_AUTH_PROVIDER=oidc`. @@ -54,10 +50,6 @@ services: - "3000:3000" environment: ADMIN_EMAIL: admin@libredb.org - ADMIN_PASSWORD: change-me - USER_EMAIL: user@libredb.org - USER_PASSWORD: change-me - JWT_SECRET: change-me-to-a-random-32-char-string STORAGE_PROVIDER: sqlite # persist on the volume below STORAGE_SQLITE_PATH: /app/data/libredb-storage.db volumes: diff --git a/README.md b/README.md index 69ae29d05..841351870 100644 --- a/README.md +++ b/README.md @@ -349,10 +349,6 @@ docker run \ --name libredb-studio \ -p 3000:3000 \ -e ADMIN_EMAIL=admin@libredb.org \ - -e ADMIN_PASSWORD=LibreDB.2026 \ - -e USER_EMAIL=user@libredb.org \ - -e USER_PASSWORD=LibreDB.2026 \ - -e JWT_SECRET=change-me-to-a-random-32-char-string \ ghcr.io/libredb/libredb-studio:latest ``` @@ -360,7 +356,7 @@ docker run \ > **IPv6**: the container picks its own bind address at startup and prefers `::`, which serves IPv4 and IPv6 through one socket — so an IPv6-only host needs no flags. It falls back to `0.0.0.0` where the namespace has no usable IPv6, and logs which it chose. Add `-e HOSTNAME=0.0.0.0` to pin it to IPv4 — details, and the Kubernetes equivalent, in [`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md#network-exposure-bind-address). - Open [http://localhost:3000](http://localhost:3000) and login with `admin@libredb.org` / `LibreDB.2026`. + Open [http://localhost:3000](http://localhost:3000). The command above sets no password, so the first start generates one and prints it to the container log with `docker logs libredb-studio` — sign in as `admin@libredb.org` with the password it printed, or set `ADMIN_PASSWORD` yourself. > **Auth env vars (local provider):** `ADMIN_PASSWORD` and `JWT_SECRET` are only required when `AUTH_BOOTSTRAP=off`; otherwise both are generated on first start (see [Zero-config first run](#zero-config-first-run) below). `USER_EMAIL` / `USER_PASSWORD` are optional; omit them to run admin-only (no default user password is ever assumed). `ADMIN_EMAIL` defaults to `admin@libredb.org`. Using OIDC (`NEXT_PUBLIC_AUTH_PROVIDER=oidc`)? None of these are needed. @@ -419,9 +415,7 @@ journalctl -u libredb-studio ```env # Authentication (email/password) ADMIN_EMAIL=admin@libredb.org - ADMIN_PASSWORD=your_admin_password USER_EMAIL=user@libredb.org - USER_PASSWORD=your_user_password JWT_SECRET=your_32_character_random_string # Optional: OIDC Single Sign-On (Auth0, Keycloak, Okta, Azure AD, etc.) @@ -616,7 +610,7 @@ The nineteenth spec in `e2e/`, `base-path.spec.ts`, is not in that 18: it needs Deploy your own instance of LibreDB Studio with a single click on DigitalOcean, Koyeb, Render, Railway, Sealos, CapRover, or Dokploy: - [![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=libredb-studio&type=docker&image=ghcr.io%2Flibredb%2Flibredb-studio%3Alatest&instance_type=free®ions=fra&instances_min=0&autoscaling_sleep_idle_delay=3900&env%5BADMIN_EMAIL%5D=admin%40libredb.org&env%5BADMIN_PASSWORD%5D=set_a_real_password&env%5BJWT_SECRET%5D=set_a_real_secret&env%5BLLM_API_KEY%5D=your_GEMINI_API_KEY&env%5BLLM_MODEL%5D=gemini-2.5-flash&env%5BLLM_PROVIDER%5D=gemini&env%5BNEXT_PUBLIC_AUTH_PROVIDER%5D=local&env%5BSTORAGE_PROVIDER%5D=local&env%5BUSER_EMAIL%5D=user%40libredb.org&env%5BUSER_PASSWORD%5D=set_a_real_password&ports=3000%3Bhttp%3B%2F&hc_protocol%5B3000%5D=tcp&hc_grace_period%5B3000%5D=5&hc_interval%5B3000%5D=30&hc_restart_limit%5B3000%5D=3&hc_timeout%5B3000%5D=5&hc_path%5B3000%5D=%2F&hc_method%5B3000%5D=get) + [![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=libredb-studio&type=docker&image=ghcr.io%2Flibredb%2Flibredb-studio%3Alatest&instance_type=free®ions=fra&instances_min=0&autoscaling_sleep_idle_delay=3900&env%5BADMIN_EMAIL%5D=admin%40libredb.org&env%5BJWT_SECRET%5D=set_a_real_secret&env%5BLLM_API_KEY%5D=your_GEMINI_API_KEY&env%5BLLM_MODEL%5D=gemini-2.5-flash&env%5BLLM_PROVIDER%5D=gemini&env%5BNEXT_PUBLIC_AUTH_PROVIDER%5D=local&env%5BSTORAGE_PROVIDER%5D=local&ports=3000%3Bhttp%3B%2F&hc_protocol%5B3000%5D=tcp&hc_grace_period%5B3000%5D=5&hc_interval%5B3000%5D=30&hc_restart_limit%5B3000%5D=3&hc_timeout%5B3000%5D=5&hc_path%5B3000%5D=%2F&hc_method%5B3000%5D=get) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/libredb/libredb-studio) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/libredb-studio?referralCode=libredb&utm_medium=integration&utm_source=template&utm_campaign=generic) [![Deploy on Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/libredb-studio) @@ -683,7 +677,7 @@ For a reverse-proxy path such as `/tools/libredb`, build with `BASE_PATH` and fo ### Koyeb 1. Use the **Deploy to Koyeb** button under [One-Click Deploy](#one-click-deploy) to run the prebuilt `ghcr.io/libredb/libredb-studio:latest` image. -2. Set a strong `JWT_SECRET` (32+ characters) and real `ADMIN_PASSWORD` / `USER_PASSWORD` in the deploy form before launching. Koyeb cannot auto-generate secrets; the prefilled values are placeholders. +2. Set a strong `JWT_SECRET` (32+ characters) in the deploy form before launching. Koyeb cannot auto-generate secrets, and the prefilled one is shorter than the 32-character minimum on purpose, so a deployment left as it stands stops at boot and says why. No password is prefilled: leave `ADMIN_PASSWORD` unset and the app generates one on first run and prints it to the Koyeb runtime log, or set your own. `USER_PASSWORD` is not generated — without it the lower-privilege account does not exist at all, which is the safer default for a public URL. 3. For connections to survive redeploys, set `STORAGE_PROVIDER=postgres` and `STORAGE_POSTGRES_URL` to a Koyeb managed Postgres or Neon connection string. The button defaults to `STORAGE_PROVIDER=local`, which keeps connection metadata in the browser. See [`deploy/koyeb/`](deploy/koyeb/) for the complete setup and storage options. diff --git a/SECURITY.md b/SECURITY.md index 15beddbea..b7b999d9d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -120,7 +120,11 @@ When using LibreDB Studio, please follow these security best practices: that fetched the rows on screen, read under the connection's own dialect, and is refused whenever that statement's rows have no single table or the reader cannot settle the name. An unquoted name is validated as a bare identifier rather than quoted, because quoting changes its - case semantics; a quoted one is copied exactly as the query spells it + case semantics; a quoted one is copied exactly as the query spells it. The key column the + `WHERE` is built on is inferred as well, from the result's own field names, so the apply asks + the engine whether that column addresses one row per value and refuses the whole apply when it + does not: a result carrying a foreign key instead of the table's own key made one cell edit + rewrite every row sharing that value - Login attempts, the AI endpoints and every database-reaching route (query execution, schema browsing, maintenance operations, and the admin fleet-health check) are rate limited in the application. The counters live in the application process, so with more than one replica the diff --git a/deploy/koyeb/README.md b/deploy/koyeb/README.md index 784ec7275..d5eca4add 100644 --- a/deploy/koyeb/README.md +++ b/deploy/koyeb/README.md @@ -31,13 +31,20 @@ by hand. URL-encode every special character (`@` → `%40`, `:` → `%3A`, ## Important Koyeb specifics - **No secret generation.** Unlike Railway's `${{ secret(48) }}`, Koyeb cannot - auto-generate values. The user **must** set a strong `JWT_SECRET` (32+ chars) - and real `ADMIN_PASSWORD` / `USER_PASSWORD` in the deploy form before - launching. The prefilled values are deliberately unusable rather than merely - nominal: the secret is **shorter than the 32-character minimum**, so a deploy - left as-is stops at boot with `JWT_SECRET is too short` instead of coming up - on a secret that is printed in a public README. Keep it that way — a - placeholder that clears the minimum is a published working secret. + auto-generate values, so the user **must** set a strong `JWT_SECRET` (32+ + chars) in the deploy form before launching. The prefilled secret is + deliberately unusable rather than merely nominal: it is **shorter than the + 32-character minimum**, so a deploy left as-is stops at boot with + `JWT_SECRET is too short` instead of coming up on a secret that is printed in + a public README. Keep it that way — a placeholder that clears the minimum is a + published working secret. +- **No prefilled passwords.** The button carries none. The two + fields behave differently when unset, and both answers are safe ones: + `ADMIN_PASSWORD` is generated on first run and printed to the Koyeb runtime + log, the same as a bare `docker run`; `USER_PASSWORD` is never generated, and + without it the lower-privilege account does not exist at all. Set your own in + the deploy form if you want to choose them, or if you want that second account; + do not put a placeholder back. - **Ephemeral filesystem.** Koyeb instances do not have a persistent disk in the button flow, so SQLite-on-disk storage (`STORAGE_PROVIDER=sqlite`) will reset on every redeploy/sleep. The button therefore defaults to @@ -56,8 +63,8 @@ for the full list. Minimum required for a working Koyeb deploy: | Variable | Notes | |----------|-------| | `JWT_SECRET` | 32+ chars, set your own | -| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | admin login | -| `USER_EMAIL` / `USER_PASSWORD` | standard user login | +| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | admin login; password not prefilled, generated and logged when unset | +| `USER_EMAIL` / `USER_PASSWORD` | optional second account; no password means no account | | `NEXT_PUBLIC_AUTH_PROVIDER` | `local` (default) or `oidc` | | `STORAGE_PROVIDER` | `local` (default); `postgres` for persistence | diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md index 08d744386..b62fdf637 100644 --- a/docs/API_DOCS.md +++ b/docs/API_DOCS.md @@ -1679,10 +1679,14 @@ including login - is refused this way. ### cURL Examples #### Login + +The admin password is generated on first run and printed to the server log, or set +through `ADMIN_PASSWORD`. Put yours in place of the placeholder below. + ```bash curl -X POST http://localhost:3000/api/auth/login \ -H "Content-Type: application/json" \ - -d '{"email": "admin@libredb.org", "password": "admin123"}' \ + -d '{"email": "admin@libredb.org", "password": ""}' \ -c cookies.txt ``` @@ -1766,7 +1770,7 @@ async function executeQuery(sql: string) { await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: 'admin@libredb.org', password: 'admin123' }), + body: JSON.stringify({ email: 'admin@libredb.org', password: process.env.ADMIN_PASSWORD }), credentials: 'include' }); diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 763761a9c..49141152f 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -1437,7 +1437,8 @@ it was not mixed into a correctness PR. ### X9. What `columnTypes` still cannot name, measured -The four string-returning drivers fill `QueryResult.columnTypes` since 2026-08-23. Four bounds were +The four string-returning drivers fill `QueryResult.columnTypes` since 2026-08-23, and +SQLite joined them on 2026-09-18 by reading its own declarations through the driver bridge. Four bounds were measured while doing it, and each is a small residue rather than a defect: - **A user-defined type has no name.** Postgres's built-in OIDs are a generated static table (they are diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 7f39eda06..3e1f61317 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -263,9 +263,8 @@ Production (strict mode, explicit secrets): ```bash docker run --name libredb-studio -p 3000:3000 \ -e AUTH_BOOTSTRAP=off \ - -e JWT_SECRET=change-me-to-a-random-32-char-string \ + -e JWT_SECRET=change-me \ -e ADMIN_EMAIL=admin@libredb.org \ - -e ADMIN_PASSWORD=your_secure_admin_password \ ghcr.io/libredb/libredb-studio:latest ``` @@ -809,7 +808,7 @@ Example drop-in (uncomment and fill what you need): # Auth (optional; omit to keep zero-config bootstrap) #Environment=AUTH_BOOTSTRAP=off -#Environment=JWT_SECRET=change-me-to-a-random-32-char-string +#Environment=JWT_SECRET=change-me #Environment=ADMIN_EMAIL=admin@libredb.org #Environment=ADMIN_PASSWORD= diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 4dfa1c1e4..b20994cc3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -21,7 +21,7 @@ ### 3. Pro Data Grid (Excel-Style) * **High Performance:** Virtualized rendering using TanStack Virtual for smooth scrolling through millions of rows. -* **Inline Editing:** Double-click any cell to edit data directly; apply pending cell changes as one `UPDATE` per edited row or discard them. The table written to is the one the *statement that fetched the rows* names, not the tab's title. A query whose rows have no single table - a join, a comma-separated `FROM`, a subquery, a CTE, a set operation - is refused with a reason rather than guessed at. Offered only where the provider declares `supportsInlineRowEdit`. ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, Cassandra, MongoDB, Redis and LibreDB show no editing control at all because they have no single-table row update - on Cassandra because CQL requires the WHOLE primary key restricted by equality while the editor names one column it guessed from the result fields, so a clustered table answers "Some partition key parts are missing" (measured) — on Trino because it declares no primary key for any table in any catalog, so the generated `WHERE` could not identify one row — on the two search engines `UPDATE` is absent from the SQL grammar itself, measured on both; Couchbase shows none because the document key reaches the grid as a projection alias the generated `WHERE` cannot address. +* **Inline Editing:** Double-click any cell to edit data directly; apply pending cell changes as one `UPDATE` per edited row or discard them. The table written to is the one the *statement that fetched the rows* names, not the tab's title. A query whose rows have no single table - a join, a comma-separated `FROM`, a subquery in `FROM` or in the select list, a CTE, a set operation - is refused with a reason rather than guessed at. The key the `WHERE` is built on is a guess off the result's own fields, so before anything is written the apply asks the engine whether that column addresses one row per value and refuses the whole apply when it does not: on a result carrying a foreign key rather than the table's own key, one cell edit used to rewrite every row sharing that value. Offered only where the provider declares `supportsInlineRowEdit`. ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, Cassandra, MongoDB, Redis and LibreDB show no editing control at all because they have no single-table row update - on Cassandra because CQL requires the WHOLE primary key restricted by equality while the editor names one column it guessed from the result fields, so a clustered table answers "Some partition key parts are missing" (measured) — on Trino because it declares no primary key for any table in any catalog, so the generated `WHERE` could not identify one row — on the two search engines `UPDATE` is absent from the SQL grammar itself, measured on both; Couchbase shows none because the document key reaches the grid as a projection alias the generated `WHERE` cannot address. * **Data-Type Formatting:** Specialized rendering for Numbers, Booleans, and Nulls. * **Column Management:** Resizable columns and advanced sorting. * **Row Detail:** A control at the left edge of every row opens that row field by field, values beside field names, with per-field copy and the same masking the grid applies. diff --git a/docs/MFA.md b/docs/MFA.md index 26335ff45..bff401add 100644 --- a/docs/MFA.md +++ b/docs/MFA.md @@ -52,7 +52,6 @@ secret for you — create a manual entry and copy the key it shows. ```env NEXT_PUBLIC_AUTH_PROVIDER=local ADMIN_EMAIL=admin@libredb.org -ADMIN_PASSWORD=your_secure_admin_password ADMIN_TOTP_SECRET=the_secret_you_generated_in_step_1 ``` @@ -130,7 +129,6 @@ you configured there. Two ways to close it, and they compose: ```bash docker run -d -p 3000:3000 \ -e JWT_SECRET="$(openssl rand -base64 32)" \ - -e ADMIN_PASSWORD=your_secure_admin_password \ -e ADMIN_TOTP_SECRET="$ADMIN_TOTP_SECRET" \ ghcr.io/libredb/libredb-studio:latest ``` diff --git a/docs/SEED_CONNECTIONS.md b/docs/SEED_CONNECTIONS.md index 184a34ead..7a3fe4d63 100644 --- a/docs/SEED_CONNECTIONS.md +++ b/docs/SEED_CONNECTIONS.md @@ -391,8 +391,6 @@ docker run \ -e SEED_CONFIG_PATH=/app/config/seed-connections.yaml \ -e PG_PASSWORD=secret \ -e JWT_SECRET=your-32-char-jwt-secret-here!! \ - -e ADMIN_PASSWORD=MyAdmin123 \ - -e USER_PASSWORD=MyUser123 \ -p 3000:3000 \ ghcr.io/libredb/libredb-studio:latest ``` @@ -410,8 +408,6 @@ services: environment: SEED_CONFIG_PATH: /app/config/seed-connections.yaml JWT_SECRET: your-32-char-jwt-secret-here!! - ADMIN_PASSWORD: MyAdmin123 - USER_PASSWORD: MyUser123 PG_PASSWORD: ${PG_PASSWORD} MYSQL_PASSWORD: ${MYSQL_PASSWORD} env_file: diff --git a/docs/providers/libsql.md b/docs/providers/libsql.md index 1e143c639..293135a15 100644 --- a/docs/providers/libsql.md +++ b/docs/providers/libsql.md @@ -129,7 +129,7 @@ path uses, so the error reader handles both. 400 is in the provider's authentica 401 and 403 for that reason: keying only on 401 would report a malformed token as a connection failure. -### 3.4 Integers arrive as decimal strings, and they stay exact +### 3.4 Integers arrive as decimal strings, and the same digits go back as integers Hrana quotes every integer — `{"type":"integer","value":"2000"}` — which is the protocol protecting 64-bit values from a double. `decodeInteger` returns a `number` when the value is exactly @@ -140,6 +140,68 @@ Trino version of the same lesson). The one place a wide integer IS parsed to a double is `readNumber` in `introspect.ts`, and only for display statistics: a row count above 2^53 is 9 quadrillion rows. Result CELLS never pass through it. +**Sending one back.** Reading exactly is only half an edit. `decodeInteger` is lossy in ONE direction: +`9007199254740993` the integer and `'9007199254740993'` the text both leave this transport as the same +JavaScript string, so a value arriving in a bind carries no clue which it was. SQLite settles that by +the COLUMN's affinity, and only for a column that HAS one. Measured 2026-09-18 against sqld 0.24.33 +(`ghcr.io/tursodatabase/libsql-server:v0.24.33`, SQLite 3.45.1), on a row whose key is +`9007199254740993`, sending each bind over `POST /v2/pipeline` both ways: + +| Column declared | `{"type":"text"}` (what the read used to send back) | `{"type":"integer"}` (what it sends now) | +|---|---|---| +| `INTEGER` / `NUMERIC` | 1 row | 1 row | +| `TEXT` | 1 row | 1 row | +| `BLOB` | **0 rows** | 1 row | +| no type at all | **0 rows** | 1 row | + +`INTEGER` and `NUMERIC` affinity convert the text to a number before comparing and `TEXT` affinity +converts the integer to text, so those answer the same either way. A column declared `BLOB` or +declared NOTHING has NO affinity: SQLite compares the operands as they stand, a text is never equal to +an integer, and **the row the grid had just read could not be found again** — `UPDATE … WHERE id = ?` +reported 0 rows changed and the editor told the user nothing had happened. + +Note what this is NOT. Unlike `bun:sqlite`, the read side here never rounds — Hrana quotes its +integers — so the damage was a silent NO-OP, never a write onto the neighbouring row +([sqlite.md §3.6](./sqlite.md#36-a-64-bit-integer-survives-the-round-trip-in-both-directions) is the +other half of that comparison). + +The affinity is not knowable at a bind — a bind is a value, and the protocol never names the column an +operand belongs to — so `encodeValue` answers the question it CAN answer exactly: **it accepts back +precisely what `decodeInteger` hands out**, and leaves every other string as text. Those digits are +emitted for one input only, a 64-bit integer outside the safe range, so reading them back as that +integer is the exact inverse: + +| Bound string | Sent as | Why | +|---|---|---| +| `"9007199254740993"`, `"-9007199254740993"`, `"9223372036854775807"` | `{"type":"integer"}` | the only shape the read emits | +| `"1"`, `"9007199254740991"` | `{"type":"text"}` | inside the safe range the read hands out a NUMBER, never digits, so such a string is the caller's own text | +| `"007"`, `"+7"`, `" 7"`, `""`, `"7.0"`, `"9e15"` | `{"type":"text"}` | shapes the read cannot emit | +| `"99999999999999999999"`, `"9223372036854775808"` | `{"type":"text"}` | wider than SQLite's own `INTEGER`, so no row could match as a number either | + +Only a real `string` is tested, never `String(param)` of some other object: the read side hands out +strings and nothing else, so nothing else can be a value it emitted. + +**The round trip, end to end through the provider against that live server.** Two rows with ids +`9007199254740992` and `9007199254740993`, read back and then edited on the id that was read: + +| Column declared | Read back | `UPDATE` on the read key | Rows afterwards | +|---|---|---|---| +| `INTEGER` | `"9007199254740992"`, `"9007199254740993"` | 1 row | `…992=neighbour`, `…993=edited` | +| `TEXT` | the same two | 1 row | the same | +| `BLOB` | the same two | 1 row | the same | +| no type at all | the same two | 1 row | the same | + +Ordinary values are untouched in the same pass: `SELECT 1` is still the number `1` and `COUNT(*)` +still a number. A genuinely textual all-digit key is still text — `'9007199254740993'` and `'007'` in +a `TEXT PRIMARY KEY` both match, and `typeof(id)` reads `text` for both — because `TEXT` affinity +converts the bind back to text. + +What that costs, measured and accepted: in a column with NO affinity that genuinely stores this shape +as TEXT, the bind now misses where it used to match. That is the same ambiguity read from the other +end, it cannot be resolved without the affinity, and the integer reading is the one these digits exist +for. This is the same rule `toSQLiteBindValue` applies in the SQLite driver, by design — the two +providers hand out the same shape, so they accept the same shape back. + ### 3.5 The server refuses four statements, so four controls are withheld Measured on both deployments, with the wording differing and the code identical: @@ -875,6 +937,7 @@ await provider.disconnect(); | No WAL size on the Storage tab | No statement reports it, and `PRAGMA wal_checkpoint` is refused | The engine's | | Turso Database (the Rust engine) is not reachable | It publishes no server image and ships in-process | Revisit when a server image exists | | No `function` object kind | `CREATE FUNCTION ... LANGUAGE wasm` is refused by the server's parser and `libsql_wasm_func_table` does not exist (§6.1) | The engine's. Declare the kind if a build ever accepts it | +| A no-affinity column holding these digits as TEXT cannot be keyed on | The bind is a value with no column attached, so the transport cannot read the affinity that would settle it (§3.4) | Ours, and accepted: the integer reading is the one the digits exist for | | The object surface reads `main` only | `ATTACH` is refused outright and a declaration is read off a provider that never connects | Ours, and Phase 1's scope | --- diff --git a/docs/providers/mysql.md b/docs/providers/mysql.md index 4dfa3c43d..1d01b27ec 100644 --- a/docs/providers/mysql.md +++ b/docs/providers/mysql.md @@ -241,7 +241,8 @@ covering `TINYINT(1)`, `INT`, `BIGINT` past 2^53, `BIGINT UNSIGNED`, `DECIMAL(20 - every value identical by `typeof` and by `JSON.stringify` — including the `Buffer` for `BLOB` and both `BIT` widths ([§3.3](#33-blob--binary-values-reach-every-surface-as-bytes)), the `Date` for the three temporal types, the string for `DECIMAL` and `TIME`, the parsed object for `JSON`, and the - same `9007199254740992` for a `BIGINT` written as `9007199254740993`; + same `"9007199254740993"` for a `BIGINT` written as `9007199254740993` — a STRING on both + protocols, because the pool asks mysql2 not to round it ([§3.7](#37-a-bigint-past-253-arrives-as-a-string)); - every `FieldPacket` identical in `columnType`, `flags`, `characterSet`, `columnLength` and `decimals`, so `columnTypes` ([§5.4](#54-declared-column-types)) names the same types either way; - a statement with no result set answers the same `ResultSetHeader` object, which is what the envelope @@ -288,6 +289,62 @@ auto-killed by the provider; cancellation is explicit via [`cancelQuery()`](#53- (`getAllTablesForMaintenance()`, capped at **50** tables, [`mysql.ts`](../../src/lib/db/providers/sql/mysql.ts)), each name quoted via `escapeIdentifier()`. With a target, the single quoted table is used. +### 3.7 A `BIGINT` past 2^53 arrives as a string + +The pool asks mysql2 for **`supportBigNumbers: true`** (`buildPoolConfig()`, +[`mysql.ts`](../../src/lib/db/providers/sql/mysql.ts)). Without it the driver hands every integer back +as a JavaScript `number`, and a `number` cannot hold a 64-bit id: **two rows whose ids differ only in +the last digit reach the browser as the same number**. The grid's inline editor then asks its key +guard about the number it was shown, is told one row matches, `UPDATE`s the NEIGHBOURING row and +reports success. + +Measured 2026-09-18 against a live MySQL 8.4.11 through `mysql2` 3.24.4 — the same `SELECT` over one +server with the option off and on, printed with `typeof` beside each value: + +| Column / expression | Stored | Option off | Option on | +|---|---|---|---| +| `BIGINT` | `9007199254740992` | `9007199254740992` (number) | `"9007199254740992"` | +| `BIGINT` | `9007199254740993` | `9007199254740992` (number) — **the row beside it** | `"9007199254740993"` | +| `BIGINT UNSIGNED` | `18446744073709551615` | `18446744073709552000` (number) | `"18446744073709551615"` | +| `BIGINT` | `42` | `42` (number) | `42` (number) | +| `BIGINT AUTO_INCREMENT` | `1` | `1` (number) | `1` (number) | +| `INT` | `7` | `7` (number) | `7` (number) | +| `DECIMAL(20,4)` | `19.99` | `"19.9900"` | `"19.9900"` | +| `COUNT(*)` | — | `3` (number) | `3` (number) | +| `SUM()` | — | `"24"` | `"24"` | +| `CAST(9007199254740991 AS SIGNED)` | — | `9007199254740991` (number) | `9007199254740991` (number) | + +**Only what a `number` cannot hold changes type.** mysql2's threshold sits ABOVE +`Number.MAX_SAFE_INTEGER`, so 2^53 - 1 is still a number and **2^53 exactly is already a string** even +though that value survives a `number` intact — the boundary is the widest exact integer, not the +widest correct one. Everything narrower is untouched, which is what the lower half of the table is +for: a small `INT`, a `BIGINT` holding a small value, an `AUTO_INCREMENT` id and `COUNT(*)` are all +still numbers. `DECIMAL` and `SUM` over an `INT` column were strings before the change and are strings +after it — MySQL answers `SUM` as `DECIMAL`, and mysql2 has always spelled `DECIMAL` as a string to +keep its precision ([§5.4](#54-declared-column-types)). + +**One shape was not merely rounded, it was impossible.** `BIGINT UNSIGNED` at the top of its range +read back as `18446744073709552000`, which is larger than the column's own maximum — no row could +hold it, so it could never match one either. + +**`bigNumberStrings` is deliberately NOT set.** It is mysql2's other big-number flag, and it turns +EVERY integer into a string — `SELECT 5` becomes `"5"`, `COUNT(*)` becomes `"3"` — changing types that +were never wrong. + +**The option is the FIRST entry in `baseConfig`, which is what makes it cover both connection forms.** +The connection-string branch returns `{ ...baseConfig, uri }` and takes the discrete-fields branch not +at all ([§4.2](#42-connection-pooling)), so an option added beside `timezone` or the SSL config would +apply to a host/port connection and silently not to a pasted URI. + +**Both wire protocols answer the same shape.** Re-measured in the same pass with the option on: the +text protocol (`conn.query`) and the prepared protocol (`conn.execute`) each return +`"9007199254740993"` and `"18446744073709551614"` for the same row, so +[§3.4](#34-which-wire-protocol-a-statement-takes)'s equivalence holds unchanged. + +The declared type is unaffected — `columnTypes` still names the column `bigint` +([§5.4](#54-declared-column-types)) — so the SQL-DDL export writes `BIGINT` for a column whose values +now arrive as strings, rather than the `TEXT` a value-shaped guess would produce. + --- ## 4. Connection @@ -316,6 +373,7 @@ options set by `buildPoolConfig()` ([`mysql.ts`](../../src/lib/db/providers/sql/ | mysql2 option | Value | Source | |---------------|-------|--------| +| `supportBigNumbers` | `true` | fixed — the first entry, so it survives the `connectionString` branch ([§3.7](#37-a-bigint-past-253-arrives-as-a-string)) | | `connectionLimit` | pool `max` (default 10) | `ProviderOptions.pool.max` | | `waitForConnections` | `true` | fixed | | `queueLimit` | `0` (unbounded queue) | fixed | @@ -512,7 +570,9 @@ table - the same source the schema tree shows - **38 of 39 match exactly**; the column declared a type. Its consumers are the results grid's column labels, the SQL-DDL export (which prefers a declared type over its own value-shaped guess) and the agent's state summary. This matters most for the types whose values arrive as strings: a `DECIMAL` reaches the browser as -`"19.99"`, so before this the DDL export wrote it as `TEXT`. +`"19.99"` and a `BIGINT` past 2^53 as `"9007199254740993"` +([§3.7](#37-a-bigint-past-253-arrives-as-a-string)), so before this the DDL export wrote them as +`TEXT`. ### 5.5 The EXPLAIN grammar is measured at connect @@ -1619,7 +1679,9 @@ types + kill validation), the full transaction lifecycle, `queryInTransaction`, overview, performance metrics, slow queries, active sessions, table/index/storage stats, every SSL branch, `prepareQuery`, error mapping (`ER_ACCESS_DENIED`, `ECONNREFUSED`), the non-SELECT envelope (DDL, `INSERT`, `UPDATE`, `DELETE`, and the transaction path) driven from real `ResultSetHeader` -literals, and the wire protocol each statement takes. +literals, the wire protocol each statement takes, and wide integers (the pool option on both +connection forms, and two ids differing only past 2^53 staying two values through `query()` and +through the JSON the API response is made of). It also covers **the object surface** ([§7.1](#71-the-object-surface-789)) in two blocks. `object surface` holds the seven conformance tests: the declared kinds and roles on each server, the diff --git a/docs/providers/sqlite.md b/docs/providers/sqlite.md index ad3ba5240..266dc14b2 100644 --- a/docs/providers/sqlite.md +++ b/docs/providers/sqlite.md @@ -77,8 +77,11 @@ SQLite driver by runtime: - **Identical behaviour:** the adapter exposes the exact `bun:sqlite`-shaped surface the provider uses (`exec` / `prepare().all/get/run` / `close`) and bridges the small `node:sqlite` deltas (`get()` miss returns `null` not `undefined`; `run().changes` normalized to `number`; - `close(throwOnError)` is bun's flag for "release the file now" and node:sqlite needs none), so - results and error mapping are the same under both runtimes. + `close(throwOnError)` is bun's flag for "release the file now" and node:sqlite needs none; the + big-integer flag is `safeIntegers` on bun and `readBigInts` on node, + [§3.6](#36-a-64-bit-integer-survives-the-round-trip-in-both-directions); the declared column types + are `columnNames` + `declaredTypes` on bun and one `columns()` on node, + [§5](#declared-column-types)), so results and error mapping are the same under both runtimes. - **Why not `better-sqlite3`?** Bun refuses to load it outright, and its native binding must match the installing runtime's ABI (a bun-installed binding fails under Node). The built-in drivers need no native dependency at all. (`better-sqlite3` remains the *storage-layer* driver.) @@ -241,6 +244,92 @@ The `scope` parameter is declared on the interface and ignored here, because thi That is the D87 shape on a single connection and it is NOT closed: closing it needs the transaction owned by a call scope rather than by a client, which is a design change and not a parameter, so it is recorded here rather than worked around. `redis.md` §5.2a and `duckdb.md` carry the same residual for the same reason, and the three were checked rather than inferred from one another. +### 3.6 A 64-bit integer survives the round trip, in both directions + +SQLite's `INTEGER` is a signed 64-bit value and a JavaScript `number` is not, so an id past 2^53 does +not survive a naive read. **Both built-in drivers got it wrong, and they got it wrong differently** — +measured 2026-09-18 reading `9007199254740993` back with each driver's own DEFAULTS, bun:sqlite under +Bun 1.4.0 (SQLite 3.51.0) and node:sqlite under Node 24.14.0 (SQLite 3.51.2): + +| Driver, defaults | Reading `9007199254740993` | +|---|---| +| `bun:sqlite` | `9007199254740992` (number) — silently the value the row BESIDE it reads | +| `node:sqlite` | throws `ERR_OUT_OF_RANGE: Value is too large to be represented as a JavaScript number: 9007199254740992` | + +Two spellings of one defect, and the silent one is the dangerous half: the grid showed two rows +carrying the same id, and the inline editor's `UPDATE … WHERE id = ` then edited the +NEIGHBOURING row and reported success. + +**The flag alone is not the fix.** Each driver can hand every integer back as a `BigInt` and each +spells the request its own way — bun `safeIntegers`, node `readBigInts` — but it is all-or-nothing: +`1`, `COUNT(*)` and every PRAGMA column become `BigInt` too, and `JSON.stringify`, which is how every +row reaches the browser, refuses a `BigInt` outright. So both adapters set their own spelling and +[`sqlite-driver.ts`](../../src/lib/db/providers/sql/sqlite-driver.ts) converts back at the one seam +every row crosses — `prepare()`, the provider's only row-returning entry point (`exec()` returns +nothing), wrapped by `withoutBigInts()` so `all()`, `get()` and `run()` are covered alike. Measured +through both adapters in the same pass, with identical answers: + +| Value read | Answered as | +|---|---| +| `1` | `1` (number) | +| `COUNT(*)` over two rows | `2` (number) | +| `9007199254740991` (`Number.MAX_SAFE_INTEGER`) | `9007199254740991` (number) | +| `9007199254740992` | `"9007199254740992"` | +| `9007199254740993` | `"9007199254740993"` | +| `-9007199254740992` | `"-9007199254740992"` | +| `9223372036854775807` (INT64's own maximum) | `"9223372036854775807"` | + +The boundary is `Number.MAX_SAFE_INTEGER`: what fits comes back AS a number, what does not comes back +as its decimal string with every digit kept, and **nothing outside the adapter ever sees a `BigInt`**. +That is the same shape the MySQL provider answers for a wide `BIGINT` +([mysql.md §3.7](./mysql.md#37-a-bigint-past-253-arrives-as-a-string)), deliberately: the two hand out +one shape. + +**And the same string is accepted back**, which is what completes the edit. The conversion above is +lossy in ONE direction: `9007199254740993` the integer and `'9007199254740993'` the text both leave +here as the same JavaScript string, so a value arriving in a bind carries no clue which it was. SQLite +settles that by the COLUMN's affinity, and only for a column that HAS one. Measured the same day on +both drivers, against a row whose key is `9007199254740993`: + +| Column declared | Bound as text (what the read used to hand back) | Bound as a 64-bit integer (what it does now) | +|---|---|---| +| `INTEGER` / `NUMERIC` | 1 row | 1 row | +| `TEXT` | 1 row | 1 row | +| `BLOB` | **0 rows** | 1 row | +| no type at all | **0 rows** | 1 row | + +`INTEGER` and `NUMERIC` affinity convert the text to a number before comparing and `TEXT` affinity +converts the integer to text, so those answer the same either way. A column declared `BLOB` or +declared NOTHING has NO affinity: SQLite compares the operands as they stand, a text is never equal to +an integer, and **the row the grid had just read could not be found again** — the `UPDATE` reported 0 +rows changed and the editor told the user nothing had happened. That is the case the round trip could +not serve at all before, not a case it served wrongly. + +The affinity is not knowable at a bind — a bind is a value with no column attached — so +`toSQLiteBindValue()` answers the question it CAN answer exactly: it accepts back precisely what the +read hands out, and leaves every other string alone. Measured, string by string: + +| Bound string | Sent as | Why | +|---|---|---| +| `"9007199254740993"`, `"-9007199254740993"`, `"9223372036854775807"` | a 64-bit integer | the only shape the read emits | +| `"1"`, `"9007199254740991"` | text | inside the safe range the read hands out a NUMBER, so digits are the caller's own text | +| `"007"`, `"+7"`, `" 7"`, `""`, `"7.0"`, `"9e15"` | text | shapes the read cannot emit | +| `"99999999999999999999"`, `"9223372036854775808"` | text | wider than SQLite's own `INTEGER`, so no row could match as a number either | + +End to end, on both drivers: reading the two ids and then `UPDATE`ing on the one that was read +changes exactly one row — the target — in an `INTEGER`, `NUMERIC`, `TEXT`, `BLOB` and undeclared +column alike, and the neighbour is untouched in all five. + +**What it costs, measured and accepted.** A row written ELSEWHERE as TEXT in a column with no +affinity now misses where it used to match: measured, `INSERT INTO na VALUES ('9007199254740993', …)` +into `CREATE TABLE na (id, label TEXT)` stores storage class `text`, reads back as those digits, and +the `UPDATE` keyed on them reports 0 rows. That is the same ambiguity read from the other end, it +cannot be resolved without the affinity, and the integer reading is the one these digits exist for. A +`TEXT`-declared column is NOT affected — `TEXT` affinity converts the bind back to text — so an +ordinary textual key still matches as text, `'007'` included (measured: both match, both stored as +`text`). A value written through THIS provider into a no-affinity column is stored as an integer and +round-trips consistently. + --- ## 4. Connection @@ -354,6 +443,55 @@ real closer so the run never terminates (`SELECT [a]] FROM t`), a confirmation p pinned by tests rather than left to be discovered. `EXPLAIN QUERY PLAN` is supported (`supportsExplain: true`, `explainFormat: "sqlite-queryplan"`) — the UI renders the plan as a tree; SQLite reports no per-node cost or timing metrics, so none are shown. +### Declared column types + +`QueryResult.columnTypes` names the type each result column was DECLARED with +(`sqlite3_column_decltype`). **This provider never filled it before** — so the SQL-DDL export named a +column by the JavaScript type of its value, and the inline editor had nothing to read a key's width +from. `query()` and `queryReadOnly()` both carry it now +([`sqlite.ts`](../../src/lib/db/providers/sql/sqlite.ts)). + +Both drivers publish the declaration and spell it differently — bun:sqlite `columnNames` beside +`declaredTypes`, node:sqlite one `columns()` answering both — so +[`sqlite-driver.ts`](../../src/lib/db/providers/sql/sqlite-driver.ts) bridges them into a single +`declaredColumns()`, exactly as it bridges `inTransaction` and the big-integer flag, and +`declaredColumnTypes()` ([column-types.ts](../../src/lib/db/providers/sql/column-types.ts)) turns +that into the field the way the four code-reporting drivers already do. + +**It is read AFTER the rows.** Measured 2026-09-18: bun:sqlite THROWS *Statement must be executed +before accessing declaredTypes* until the statement has run, while node:sqlite answers either way — so +after the rows is the one order both drivers accept, and that is why the declarations are read off the +same statement object that produced them. A statement that matched NO rows still answers +(`SELECT i, r FROM dt WHERE i = -1` → `INTEGER`, `REAL`, zero rows), so an empty result is described +rather than guessed at, and a write answers an EMPTY list on both drivers. + +**An absent declaration stays absent rather than becoming a guess.** SQLite declares nothing for +anything it computed, and the key is simply omitted. Measured over one statement, identically on both +drivers: + +| Result column | Declared | +|---|---| +| `i INTEGER`, `r REAL`, `txt TEXT`, `n NUMERIC`, `b BLOB`, `ts DATETIME` | `INTEGER`, `REAL`, `TEXT`, `NUMERIC`, `BLOB`, `DATETIME` — the schema's own words, unchanged | +| a column declared with no type at all | *absent* | +| an expression (`i + 1`) | *absent* | +| a literal (`42`) | *absent* | +| an aggregate (`COUNT(*)`) | *absent* | +| a function call (`upper(txt)`) | *absent* | +| every column of a PRAGMA (`PRAGMA table_info`) | *absent* | + +**NOT bun:sqlite's `columnTypes`, which is a different question wearing a similar name.** Measured the +same day on the same table: it reports the RUNTIME storage class of the row just read, so the `REAL` +column answers `FLOAT` where its declaration is `REAL`, and the UNDECLARED column holding `7` answers +`INTEGER` where there is no declaration at all. It also throws on anything that is not a read-only +statement — *columnTypes is not available for non-read-only statements* — `PRAGMA journal_mode` +included. Reading it here would have typed every float column wrong and broken every PRAGMA this +provider runs. + +A 64-bit id is where the two features meet: it leaves as the decimal string +[§3.6](#36-a-64-bit-integer-survives-the-round-trip-in-both-directions) prints, and it is still +declared `INTEGER`, so the export writes an `INTEGER` column rather than the `TEXT` a value-shaped +guess would produce. + --- ## 6. Schema introspection @@ -967,7 +1105,11 @@ SQL execution, schema PRAGMAs, maintenance, and monitoring end-to-end. ### 11.2 Coverage Validation, connect/disconnect, path handling (NUL rejection, `..` acceptance), query (read + -write), capabilities, health, maintenance +write), 64-bit integers past 2^53 (both ids read whole, the `UPDATE` landing on the row that was +read, no `BigInt` on any public path, ordinary integers and PRAGMA columns unchanged, and the same +on the agent read-only path), declared column types (the computed column that declares nothing, the +empty result, the write, two columns of one name, the storage-class trap, and what the SQL export and +the row editor read off them), capabilities, health, maintenance (vacuum/analyze/reindex/check), overview, performance, active sessions, slow queries, table/index/storage stats, `getMonitoringData`, `prepareQuery`, and labels. For the object surface ([§6.1](#61-the-object-surface-789)): the declared kinds, the conformance contract, the tree's root @@ -1184,6 +1326,12 @@ not apply to SQLite ([§3.4](#34-no-transactions-api-no-cancellation-no-pool)). ([§7.2](#72-per-table-size-depends-on-the-sqlite-build-behind-the-driver)). `getIndexStats()` still reports `indexSize: "N/A"` per index even where `dbstat` exists — the per-table index bytes it feeds the Storage tab are measured, the per-index rows are not yet. +- **A key declared `REAL` still cannot hold a 64-bit id**, and nothing here can change that: `REAL` + affinity converts on INSERT, so the collapse happens in the FILE before any driver sees it. + Measured 2026-09-18 — `9007199254740992` and `9007199254740993` inserted into a `REAL` column both + read back as `9007199254740992` with storage class `real`, so the two rows are genuinely + indistinguishable on disk ([§3.6](#36-a-64-bit-integer-survives-the-round-trip-in-both-directions) + repairs the INTEGER case only). - **`:memory:` is ephemeral** — data is lost on disconnect; intended for trials/tests. - **Single schema (`main`)** — `ATTACH`ed databases are not surfaced. - **No path sandboxing (by design).** `getDatabasePath()` validates only that the path contains diff --git a/packaging/linux/env b/packaging/linux/env index a6dd396a3..09167af8e 100644 --- a/packaging/linux/env +++ b/packaging/linux/env @@ -14,7 +14,7 @@ # once to the journal: journalctl -u libredb-studio # Set AUTH_BOOTSTRAP=off to require explicit values instead. #AUTH_BOOTSTRAP=off -#JWT_SECRET=change-me-to-a-random-32-char-string +#JWT_SECRET=change-me #ADMIN_EMAIL=admin@libredb.org #ADMIN_PASSWORD= #USER_EMAIL=user@libredb.org diff --git a/src/components/SchemaDiff.tsx b/src/components/SchemaDiff.tsx index 5e820ebeb..c8f95e2e6 100644 --- a/src/components/SchemaDiff.tsx +++ b/src/components/SchemaDiff.tsx @@ -1,7 +1,8 @@ "use client"; import { appFetch } from "@/lib/config/base-path"; -import React, { useState, useMemo, useCallback, useEffect } from "react"; +import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; +import { useReadGeneration } from "@/hooks/use-read-generation"; import { GitCompare, Plus, @@ -13,6 +14,7 @@ import { ChevronDown, Clock, Database, + RefreshCw, TriangleAlert, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -24,6 +26,7 @@ import { detailedObjects, type DetailedObject } from "@/lib/db/detailed-object"; import { relationKindIds } from "@/lib/db/object-kinds"; import type { ProviderCapabilities } from "@/lib/db/types"; import { storage } from "@/lib/storage"; +import { newLocalId } from "@/lib/ids"; import { logger } from "@/lib/logger"; import { useAllConnections } from "@/hooks/use-all-connections"; import { diffSchemas } from "@/lib/schema-diff/diff-engine"; @@ -85,32 +88,112 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { const [showMigration, setShowMigration] = useState(false); const [snapshotLabel, setSnapshotLabel] = useState(""); const [showLabelInput, setShowLabelInput] = useState(false); + /** True while a snapshot's own read is in flight. State, because the button reads it. */ + const [snapshotting, setSnapshotting] = useState(false); + /** + * The same fact as a ref, because the GUARD cannot read the state. + * + * Two Enter presses land in the same tick, before React has re-rendered, so both see the + * `snapshotting` the callback closed over — `false` — and both save. A ref is written and + * read synchronously, which is what a re-entrancy guard needs. + */ + const snapshotInFlight = useRef(false); + /** + * The reason the last snapshot was not saved, and the connection it was about. + * + * Carried together for the reason `liveRead` is: a failure on the connection the user has + * left is not a failure of the one they are looking at, and a banner about the other + * database over a working panel is its own small lie. Derived rather than cleared by an + * effect, so there is no render where the wrong one is on screen. + * + * THREE things clear it and no read is among them: a new attempt (`takeSnapshot` clears it + * before it begins, so the report always describes the latest press of Save), a snapshot + * that succeeds, and the Dismiss on the banner. A read of the connection that works used to + * clear it as well, which is how the message the whole fix existed for lasted one tick - + * the read that overtakes a snapshot IS such a read. What a read may say is whether Current + * Schema is fresh; that is `liveRead.error`, a different fact on its own line, and the two + * never write each other. + */ + const [snapshotFailure, setSnapshotFailure] = useState<{ connectionId: string; reason: string } | null>(null); /** - * The objects the database holds right now, read when this panel opens. + * Which read is the current one. + * + * Three things read this connection now - the panel opening, a snapshot, and a target + * being chosen to compare against - and any of them can settle after another has already + * started. A counter is what settles that, and the repository already states the rule + * once in `useReadGeneration`: begin a read, and every write it performs asks first + * whether it is still the one that matters. * - * `schema` is the prop the explorer already had, and it is what "Current Schema" used to - * mean — so a diff taken right after a DDL change compared a copy of the schema from - * before the change and answered "No differences found" (#884). The panel is mounted when - * the user opens it, so reading here is the moment that matters for that sequence. + * Comparing the connection OBJECT instead was the earlier attempt and it is not safe: + * `use-connection-adapter.ts` builds `activeConnection` with a `useMemo` over a prop the + * embedded host supplies, so a host that hands over a fresh array per render produces a + * fresh object per render, and a read would then be discarded on a connection that never + * changed - the snapshot silently not saved, with nothing on screen. + */ + const reads = useReadGeneration(); + + /** + * The same rule for the OTHER connection's reads, on a counter of its own. * - * `null` until the read lands, and the prop stands in meanwhile: an empty side would - * report every object as removed, which is worse than being briefly out of date. + * `fetchRemoteSchema` was outside any counter, so two of them in a row raced: whichever + * landed LAST wrote its snapshot and made itself the target, which is the connection the + * user asked for FIRST, and whichever landed first cleared "Fetching..." while the other + * was still out. It gets its own counter rather than sharing `reads`, because the two + * sequence different things: a remote read is a read of a DIFFERENT database, and + * putting them on one counter would have the panel's own read of the connection on screen + * discarded because someone fetched a schema from somewhere else - Current Schema then + * silently falling back to the explorer's copy, which is the defect this panel exists to + * have stopped doing. */ + const remoteReads = useReadGeneration(); + /** - * The last read, and the connection it was a read OF. + * Nothing this panel started may write after the panel is gone. * - * The connection is stored with the result rather than the result being cleared when the - * connection changes, because the panel outlives a switch: `BottomPanel` keeps it mounted, - * so holding the previous database's objects made "Current Schema" mean the OTHER - * connection until the new read landed, and for good if that read failed. `takeSnapshot` - * then wrote the new connection's id and type onto the old one's objects, which is the - * stale-copy defect #884 is about, kept for as long as the snapshot is. + * `BottomPanel` mounts one view at a time, so LEAVING the Diff tab unmounts this. The + * label input and Cancel are locked while a snapshot's read is in flight, to stop the save + * being closed out from under it - and changing tabs walked straight past that lock: the + * read settled afterwards and the snapshot was written for a panel that was gone, with no + * banner, no refreshed list and nothing on screen that it had happened. Superseding both + * counters on the way out makes every write those reads still intend to perform ask a + * question whose answer is already no. + * + * Both counters are stable for the life of the hook, so this cleanup runs on unmount and + * at no other time - it does not supersede reads on an ordinary re-render. + * + * The counters are not the whole answer, and `mounted` is the rest of it. A counter says + * whether a read is still the one that MATTERS; it cannot say whether anyone is left to + * be told, and those are different questions the moment a write runs WHATEVER the counter + * answers. Three do: a superseded snapshot reports "press Save again", and both `finally` + * blocks hand their button back deliberately unconditionally, because a read that loses + * the race still has to stop the panel reading "Reading..." for good. Every one of them is + * right while the panel is on screen and is a write to a dead component after it is gone. + * So the same cleanup that supersedes the reads also puts this down, and the three writes + * that do not ask the counter ask this instead - one bit, set in one place, rather than a + * second mechanism beside the first. + * + * Re-armed in the effect BODY, not just initialised: React's StrictMode mounts, runs this + * cleanup and mounts again, and a flag only ever set to false there would leave the panel + * unable to report anything for the rest of its life in development. + */ + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + reads.supersede(); + remoteReads.supersede(); + }; + }, [reads, remoteReads]); + + /** + * The objects the database holds, and the connection they were read FROM. * - * Carrying the connection makes a stale read unusable rather than something a second - * effect has to remember to clear, and it is keyed on the connection OBJECT — the same - * thing the effect depends on — so a read can never outlive the exact render that asked - * for it. + * Carried together rather than cleared on a switch, because the panel outlives one: + * holding the previous database's objects made "Current Schema" mean the OTHER connection + * until the new read landed, and for good if that read failed. * * `error` is the other half. The panel falls back to the explorer's copy, and that copy is * precisely what #884 is about, so the reason is state that reaches the screen rather than @@ -122,64 +205,271 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { error: string | null; } | null>(null); - const readForThisConnection = liveRead?.connection === connection ? liveRead : null; + const readForThisConnection = liveRead?.connection.id === connection?.id ? liveRead : null; + const liveSchema = readForThisConnection?.objects ?? null; + const liveSchemaError = readForThisConnection?.error ?? null; + const snapshotError = + snapshotFailure !== null && snapshotFailure.connectionId === connection?.id ? snapshotFailure.reason : null; /** - * The objects the database holds right now, read when this panel opens. + * Begin a read of this connection, and hand back both the promise and the question every + * write it performs has to ask first. * - * `schema` is the prop the explorer already had, and it is what "Current Schema" used to - * mean — so a diff taken right after a DDL change compared a copy of the schema from - * before the change and answered "No differences found" (#884). The panel is mounted when - * the user opens it, so reading here is the moment that matters for that sequence. - * - * `null` until the read lands, and the prop stands in meanwhile: an empty side would - * report every object as removed, which is worse than being briefly out of date. + * The write is left to the caller rather than done here, and deliberately: a `setState` + * reached through a helper called straight from an effect is what the React lint rules + * forbid, and the shape they accept - settle first, then write - is also the honest one, + * because the two callers want different things from a failure. The panel opening falls + * back to the explorer's copy and says so; a snapshot saves nothing at all. */ - const liveSchema = readForThisConnection?.objects ?? null; - const liveSchemaError = readForThisConnection?.error ?? null; + const beginRead = useCallback( + (conn: DatabaseConnection) => ({ read: readLiveSchema(conn), isCurrent: reads.begin() }), + [reads], + ); useEffect(() => { if (!connection) return; - let cancelled = false; - readLiveSchema(connection) + const { read, isCurrent } = beginRead(connection); + read .then((objects) => { - if (!cancelled) setLiveRead({ connection, objects, error: null }); + if (!isCurrent()) return; + setLiveRead({ connection, objects, error: null }); + // The snapshot report is NOT touched here, and that is the whole of this fix. This + // write says one thing - Current Schema is fresh - and a snapshot that was not + // written stays not written however many reads land afterwards. Clearing it here put + // the silence straight back, because the read that OVERTAKES a snapshot is exactly a + // read of this connection that succeeds: in the ordinary order the overtaken one + // answers first and raises the banner, this one answers a tick later and wiped it. + // The stale banner this used to guard against is answered by the two things that + // really do spend the report - pressing Save again, which clears it at the start of + // the attempt, and the Dismiss on the banner itself - and by the connection it + // carries, which keeps it off a database the user is not looking at. }) .catch((err) => { const reason = err instanceof Error ? err.message : String(err); - if (!cancelled) setLiveRead({ connection, objects: null, error: reason }); + if (isCurrent()) setLiveRead({ connection, objects: null, error: reason }); logger.warn("Failed to read the current schema for a diff; falling back to the explorer's copy", { route: "SchemaDiff", error: reason, }); }); - return () => { - cancelled = true; - }; - }, [connection]); + }, [connection, beginRead]); + + /** + * A comparison reads the database again. + * + * Without this the panel answers the question it was opened with rather than the one being + * asked: take a snapshot, change the database, pick that snapshot as the target, and both + * sides are the moment of the snapshot - "No differences found" again, which is the whole + * defect wearing different clothes. The read happens when a target is CHOSEN, because that + * is the moment a person asks to be told the difference. + */ + useEffect(() => { + // Only when one side of the comparison IS the database. Two snapshots against each + // other are two files; reading the connection for them is a round trip that changes + // nothing either side shows. + if (!connection || !targetId) return; + if (sourceId !== "current" && targetId !== "current") return; + const { read, isCurrent } = beginRead(connection); + read + .then((objects) => { + if (!isCurrent()) return; + setLiveRead({ connection, objects, error: null }); + // Same rule as the read when the panel opens, and this is the call site that did the + // damage: choosing a target is the commonest way a snapshot in flight gets overtaken, + // so this read and the snapshot it superseded are two halves of one gesture. It + // reports on Current Schema and on nothing else. + }) + .catch((err) => { + const reason = err instanceof Error ? err.message : String(err); + // Falling back to the last copy rather than emptying the side, which would report + // every object as removed; the banner says why it may be out of date. + if (isCurrent()) setLiveRead({ connection, objects: null, error: reason }); + }); + }, [targetId, sourceId, connection, beginRead]); + + /** True while the Refresh button's own read is in flight. State, because the button reads it. */ + const [refreshing, setRefreshing] = useState(false); + /** + * The same fact as a ref, because the GUARD cannot read the state - exactly as + * `snapshotInFlight` cannot read `snapshotting`. + * + * Two clicks land in the same tick, before React has re-rendered, so both see the + * `refreshing` the callback closed over - `false` - and the `disabled` that would have + * stopped the second is not on the button yet. The counter keeps the older read from + * WRITING, so the data stays right; what breaks is the screen, and it is the thing + * `fetchRemoteSchema` was fixed for: the first read to settle runs the `finally` and hands + * the button back to "Refresh" while the read the user is waiting for is still out. + */ + const refreshInFlight = useRef(false); + + /** + * Read the database again on demand. + * + * The effect above reads when a target is CHOSEN, and "chosen" is a value CHANGING: the + * Select reports a selection only when it lands on something else, so picking the target + * that is already picked re-renders nothing and re-reads nothing. Every other way of + * asking is worse - there is no target at all to re-pick before one is chosen, and when + * the target is a snapshot the only way to make the value change is to select something + * else and come back, which reads the database twice to answer one question. So the panel + * shipped with exactly one way to see a change: leave the Diff tab and return, because + * `BottomPanel` mounts one view at a time and returning is a remount. That is the step + * this whole piece of work exists to remove, and it was still the only one. + * + * A button, therefore, rather than a cleverer rule about the Select. It works from any + * state the panel can be in, it says what it does, and it is the one affordance a person + * does not have to be told about. It goes through the same counter as every other read of + * this connection, so a refresh and a snapshot in flight cannot both decide what Current + * Schema means - the newer one wins and the older says so, exactly as choosing a target + * already does. + * + * Pressed TWICE in one tick it reads once: `disabled` is a re-render behind the second + * click, so the ref is what actually makes a second read impossible rather than unlikely. + */ + const refreshCurrentSchema = useCallback(() => { + if (!connection || refreshInFlight.current) return; + refreshInFlight.current = true; + const { read, isCurrent } = beginRead(connection); + setRefreshing(true); + read + .then((objects) => { + if (isCurrent()) setLiveRead({ connection, objects, error: null }); + }) + .catch((err) => { + const reason = err instanceof Error ? err.message : String(err); + // Same fallback as the other two reads: the last copy rather than an empty side, + // which would report every object as removed, and a banner saying why. + if (isCurrent()) setLiveRead({ connection, objects: null, error: reason }); + logger.warn("Failed to re-read the current schema for a diff", { + route: "SchemaDiff", + error: reason, + }); + }) + .finally(() => { + // Unconditional: a refresh that is superseded still has to give the button back, + // and the button is disabled while it is true. Asking `isCurrent()` here instead + // would leave it disabled for good, because a snapshot or a target being chosen + // supersedes this read on the SAME counter - and the guard above means the read + // running this `finally` is the only refresh there is. + // + // Unconditional on the COUNTER, that is. There is no button to give back once the + // panel has left the screen, so the state write asks `mounted` - the one question + // the counter cannot answer. The ref beside it stays unguarded on purpose: it dies + // with the component, and a remount builds a fresh one. + refreshInFlight.current = false; + if (mounted.current) setRefreshing(false); + }); + }, [connection, beginRead]); /** What "Current Schema" means on both sides of the diff, and in a new snapshot. */ const currentSchema = liveSchema ?? schema; - // Take snapshot of current schema - const takeSnapshot = useCallback(() => { - if (!connection) return; - const snapshot: SchemaSnapshot = { - id: Date.now().toString(), - connectionId: connection.id, - connectionName: connection.name, - databaseType: connection.type, - // The live read, not the prop: a snapshot taken from a stale copy is stale for as - // long as it is kept, and it is kept to be compared against later (#884). - schema: JSON.parse(JSON.stringify(currentSchema)), - createdAt: new Date(), - label: snapshotLabel.trim() || undefined, - }; - storage.saveSchemaSnapshot(snapshot); - setSnapshots(storage.getSchemaSnapshots()); - setSnapshotLabel(""); - setShowLabelInput(false); - }, [currentSchema, connection, snapshotLabel]); + /** + * Freeze the schema the database holds AT THIS MOMENT, not the one the panel read when + * it opened. + * + * #884 moved "Current Schema" off the explorer's 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 for as long as the panel stays open. The sequence the Diff tab exists for — + * snapshot, change the database, compare — still answered "No differences found": the + * snapshot froze that first copy, and so did the other side of the comparison. Measured + * against PostgreSQL 16 with the panel left open. Leaving the tab and coming back was + * the only thing that helped, and it helped because `BottomPanel` mounts one view at a + * time, so returning is a remount and the effect runs again — not a step anyone would + * guess, and not one the panel tells you about. + * + * Reading here fixes both halves at once, because the same read becomes the new + * `liveRead`: the snapshot records the database, and the "Current Schema" it will be + * compared against is refreshed to the same instant. + * + * A read that fails saves NOTHING. A snapshot is kept to be compared against later, so a + * silently stale one is the defect again with a longer fuse; the banner says why and the + * label stays typed so the button can be pressed again. + */ + const takeSnapshot = useCallback(async () => { + if (!connection || snapshotInFlight.current) return; + snapshotInFlight.current = true; + setSnapshotting(true); + setSnapshotFailure(null); + try { + // The same read that becomes "Current Schema", so the snapshot and the side it will + // be compared against are the same instant. + const { read, isCurrent } = beginRead(connection); + const objects = await read; + if (!isCurrent()) { + // Something asked for a newer read while this one was in flight - choosing a target + // does, and so does the embedded host handing over a fresh connection object with the + // same id. Returning quietly here saved nothing and said nothing, so the button came + // back to "Save" and the user believed it had. The banner stays until the user acts: + // a later read landing is not a snapshot, and clearing it on one put the silence + // straight back. Pressing Save again is the retry, Dismiss is the way out. + // + // Unless the panel is what superseded it. Leaving the Diff tab supersedes both + // counters on the way out, so an unmount arrives here looking exactly like a target + // being chosen - and "Press Save again" is addressed to somebody standing in front + // of a panel that no longer exists. There is no banner to raise and no Save to press; + // the write is a write to a dead component, so it is not made. + if (mounted.current) { + setSnapshotFailure({ + connectionId: connection.id, + reason: "the schema was read again before this finished. Press Save again", + }); + } + return; + } + setLiveRead({ connection, objects, error: null }); + const snapshot: SchemaSnapshot = { + // Random, not the clock. `Date.now()` is the id two snapshots taken in the same + // millisecond SHARE, and every use of a snapshot id keys on it being one snapshot: + // React lists the two Selects by it and complains about duplicate keys, Delete on + // either one removes BOTH because `deleteSchemaSnapshot` filters by id, and the + // store keeps only the last 50 - so when the twin that is still selected falls off + // that end, `?.schema || []` hands the diff an empty side and the panel reports the + // WHOLE database as removed. Same millisecond is not exotic here: Save twice, or a + // remote fetch beside a snapshot, and the clock has not moved. `newLocalId` is the + // generator this repository already uses for the things a browser names for itself, + // and it works on the plain-HTTP channels where `crypto.randomUUID` is undefined. + id: newLocalId(), + connectionId: connection.id, + connectionName: connection.name, + databaseType: connection.type, + schema: JSON.parse(JSON.stringify(objects)), + createdAt: new Date(), + label: snapshotLabel.trim() || undefined, + }; + // Inside the try as well: snapshots live in localStorage and a snapshot is a whole + // schema, so a quota refusal is an ordinary outcome rather than an exotic one. + // Inside the try, so a write that throws reaches the same banner the read failure + // does rather than escaping as an unhandled rejection. + // + // It does NOT catch a full disk. `storage.saveSchemaSnapshot` returns nothing and + // `local-storage.ts` swallows the quota error, so a refused write is reported here as + // a snapshot taken. That is the store's to fix - every caller of it has the same + // problem and none of them can see the failure - and it predates this change. + storage.saveSchemaSnapshot(snapshot); + setSnapshots(storage.getSchemaSnapshots()); + setSnapshotLabel(""); + setShowLabelInput(false); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + // A read can fail AFTER the panel has gone - the tab is changed, the request then + // times out - and the banner it would raise has no screen to be raised on. The log + // below is made either way: a log is a record of what the database said, which is the + // rule `fetchRemoteSchema` already states, and it is the only trace left of a read + // that failed for a panel nobody was watching. + if (mounted.current) setSnapshotFailure({ connectionId: connection.id, reason }); + logger.warn("Nothing was saved for this snapshot", { route: "SchemaDiff", error: reason }); + } finally { + // In `finally`, not at the end of each branch: a throw between them would otherwise + // leave the button reading "Reading..." for the life of the panel, with nothing on + // screen saying why, and `snapshotInFlight` stuck true so no later press does anything. + // + // Which is a reason to run it whatever the COUNTER says, not whatever is left of the + // panel: the button it hands back is gone once the panel is, so the state write asks + // `mounted` first. The ref is left alone for the reason the refresh one is. + snapshotInFlight.current = false; + if (mounted.current) setSnapshotting(false); + } + }, [connection, snapshotLabel, beginRead]); // Delete snapshot const deleteSnapshot = useCallback( @@ -192,20 +482,55 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { [sourceId, targetId], ); + /** + * The snapshot a side names that the store cannot produce, if there is one. + * + * `?.schema || []` used to stand in the memo below, and that empty array is a second + * defect wearing the id's clothes: "this snapshot is gone" and "this schema was empty" + * are different statements, and the fallback quietly turned the first into the second. + * The panel then answered with every table and every column listed as REMOVED - a + * catastrophe that never happened, on the one screen a user consults to find out whether + * one did. An id that cannot collide stops two snapshots sharing a row; it does not stop + * a lookup from failing, so it cannot be the whole of this. + * + * A side goes missing for reasons that have nothing to do with a colliding id, which is + * why this is not the id fix repeated: the store keeps the last 50 snapshots, so a + * selected one falls off that end once 50 more are taken; localStorage is shared between + * tabs, so another tab can delete the one this panel is pointing at; and clearing site + * data empties the store under a panel that is still open. `deleteSnapshot` puts both + * Selects back when it is the one doing the deleting, and that was the only route ever + * covered - it cannot see any of the three above. + * + * "current" is never looked up: it is not a stored record, and the live side has its own + * fallback to the explorer's copy and its own banner when a read fails. + */ + const missingSnapshotId = useMemo(() => { + if (!targetId) return null; + const absent = (id: string) => id !== "current" && !snapshots.some((s) => s.id === id); + if (absent(sourceId)) return sourceId; + if (absent(targetId)) return targetId; + return null; + }, [sourceId, targetId, snapshots]); + // Compute diff const diff = useMemo(() => { if (!targetId) return null; - - const sourceSchema = - sourceId === "current" ? currentSchema : snapshots.find((s) => s.id === sourceId)?.schema || []; - - const targetSchema = - targetId === "current" ? currentSchema : snapshots.find((s) => s.id === targetId)?.schema || []; - if (sourceId === targetId) return null; + // A side that does not exist ends the comparison. The diff engine is not asked a + // question whose only possible answer is a fiction; the panel says what is actually + // wrong instead, rendered from `missingSnapshotId`. + if (missingSnapshotId) return null; + + const side = (id: string) => (id === "current" ? currentSchema : snapshots.find((s) => s.id === id)?.schema); + const sourceSchema = side(sourceId); + const targetSchema = side(targetId); + // The same fact as the guard above, said again in the one place that would otherwise + // need a `[]` to satisfy the types. Not a second mechanism: a resolution that fails + // ends the comparison here too, rather than standing an empty array in for a schema. + if (!sourceSchema || !targetSchema) return null; return diffSchemas(sourceSchema, targetSchema); - }, [sourceId, targetId, currentSchema, snapshots]); + }, [sourceId, targetId, currentSchema, snapshots, missingSnapshotId]); // Generate migration SQL const migrationSQL = useMemo(() => { @@ -217,6 +542,25 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { // Get all connections for cross-connection comparison const { connections: allConnections } = useAllConnections(); const [fetchingRemote, setFetchingRemote] = useState(false); + /** + * Why the last remote fetch brought nothing back, and which database was asked. + * + * The failure reached the log and nowhere else: the spinner went down, the target stayed + * where it was, and the panel went on showing the comparison the user had just asked to + * replace - so the screen says "this is that database" when it is not, which is the + * silent-stale-side defect this panel exists to have stopped, entered by another door. + * + * Its own state rather than `snapshotFailure`, though the banner below is deliberately + * the same shape and the same Dismiss: that report is about the connection ON SCREEN and + * is keyed to it, this one is about ANOTHER database, and pressing Save must not spend a + * report about a fetch it has nothing to do with. + * + * Spent where the snapshot report is spent and nowhere else - at the start of the next + * attempt, which is also what a fetch that WORKS clears it with, and the Dismiss on the + * banner. No read clears it: a later read landing is not the fetch that failed, and + * clearing on one is how the snapshot message used to last a single tick. + */ + const [remoteFailure, setRemoteFailure] = useState<{ connectionName: string; reason: string } | null>(null); // Fetch schema from a remote connection const fetchRemoteSchema = useCallback( @@ -224,13 +568,29 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { const conn = allConnections.find((c) => c.id === connId); if (!conn) return; + // Sequenced like every other read in this panel, and it was the one that was not. + // Pick one connection, change your mind, pick another: two reads are out, and the + // one that answers LAST wrote its snapshot and made itself the target - so the + // target ended up being the database asked for first, chosen by network timing. The + // one that answered first cleared "Fetching..." while the other was still running, + // so the panel also said it had finished when it had not. + const isCurrent = remoteReads.begin(); setFetchingRemote(true); + // Cleared at the START of the attempt, exactly as `takeSnapshot` clears its own + // report: the message always describes the latest thing the user asked for, so a + // fetch that works leaves nothing of the one that failed behind it. + setRemoteFailure(null); try { const objects = await readLiveSchema(conn); + // A superseded fetch writes NOTHING: not the snapshot, which would litter the list + // with a database the user turned away from, and above all not the target. + if (!isCurrent()) return; // Auto-save as snapshot const snapshot: SchemaSnapshot = { - id: `remote-${Date.now()}`, + // Random for the reason the snapshot above is: `remote-${Date.now()}` collides + // with a second fetch in the same millisecond exactly as the clock id did. + id: `remote-${newLocalId()}`, connectionId: conn.id, connectionName: conn.name, databaseType: conn.type, @@ -242,15 +602,63 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { setSnapshots(storage.getSchemaSnapshots()); setTargetId(snapshot.id); } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + // Logged whether or not it is still the current read: a log is a record of what the + // database said, not a claim on the screen. logger.warn("Failed to fetch the remote schema for a diff", { route: "SchemaDiff", - error: err instanceof Error ? err.message : String(err), + error: reason, }); + // On screen only for the read the user is actually waiting on, and through the same + // counter that decides who may write the target - the one question a failure has to + // ask before it speaks. A connection turned away from, failing afterwards, is not + // the question being answered; and the unmount supersedes this counter too, so + // there is no banner raised on a panel that has left the screen. + if (isCurrent()) setRemoteFailure({ connectionName: conn.name, reason }); } finally { - setFetchingRemote(false); + // Only the current read may say the fetching is over. A stale one clearing this is + // the spinner disappearing while a fetch the user is waiting for is still out. + if (isCurrent()) setFetchingRemote(false); } }, - [allConnections], + [allConnections, remoteReads], + ); + + /** + * The user choosing a target that is already stored - which SUPERSEDES the remote side. + * + * The counter above sequences remote reads against each other and was never the whole + * race, because a remote read is not the only thing that sets the target and the other + * thing is instant: this writes it in the same tick as the click. So pick a connection, + * change your mind, pick a snapshot from the list, and the read you turned away from + * landed afterwards and made ITSELF the target - the panel showing a comparison nobody + * asked for, and the choice the user actually made gone from under them (#45). + * + * `supersede()` rather than a third mechanism, and rather than a flag the fetch consults: + * it is the counter that is already there, saying the one thing that has to be true - a + * read that was running when the user chose something else is no longer the read that + * matters, so every write it still intends to perform asks a question whose answer is now + * no. That covers the snapshot as well as the target, because `fetchRemoteSchema` asks + * once, before either. + * + * The busy indicator is put down HERE and nowhere else, and it has to be: superseding is + * what stops the abandoned read running `setFetchingRemote(false)` in its `finally`, so + * leaving it would hang "Fetching..." on screen for the life of the panel with nothing + * outstanding behind it. It is also the honest reading - nothing the user is waiting for + * is out any more. A LATER fetch raises it again on its own `begin()`, and only that + * newest read may clear it, which is the rule the counter held before this and still does. + * + * Both ways of choosing a stored target go through here - the Target select and the + * timeline's Compare, which is on screen precisely while a remote fetch has not landed - + * so the defect is closed at the choice rather than at one of its two buttons. + */ + const chooseTarget = useCallback( + (id: string) => { + remoteReads.supersede(); + setFetchingRemote(false); + setTargetId(id); + }, + [remoteReads], ); const getActionBadge = (action: string) => { @@ -330,7 +738,7 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { if (v.startsWith("conn:")) { fetchRemoteSchema(v.replace("conn:", "")); } else { - setTargetId(v); + chooseTarget(v); } }} > @@ -376,6 +784,20 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) {
+ {/* Read the database again on demand. The only way to see a change used to be + leaving the tab and coming back, because that remounts the panel - a step nobody + would guess and the one this work exists to remove. */} + + {/* Snapshot controls */} {showLabelInput ? (
@@ -385,16 +807,26 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { value={snapshotLabel} onChange={(e) => setSnapshotLabel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && takeSnapshot()} - className="h-7 px-2 text-xs bg-fill border border-hairline-strong rounded text-fg-secondary focus:outline-none focus:border-brand-tint w-32" + disabled={snapshotting} + className="h-7 px-2 text-xs bg-fill border border-hairline-strong rounded text-fg-secondary focus:outline-none focus:border-brand-tint w-32 disabled:opacity-60" autoFocus /> -
)} + {/* Its own line, because the panel's read can be failing at the same time and the two + are different facts: one says what Current Schema means, the other says a snapshot + you asked for was not written. Showing only the first left the second silent. */} + {snapshotError !== null && ( +
+ + {`No snapshot was saved: ${snapshotError}`} + {/* The way out, and it is a button rather than a rule about which reads clear the + report - a rule is what wiped the message in the first place. Nothing else here + is an exit a user could rely on: pressing Save again is a retry that can fail + again, leaving the connection only hides the report until they come back, and + "it goes away when you reopen the panel" is something they would have to guess. + This clears the report and nothing else; the panel's own warning above is + derived from the last read and has its own way out, the next read that works. */} + +
+ )} + + {/* A third line, beside the two above, because it is a third fact: not what Current + Schema means, and not a snapshot that went unwritten, but a database the user asked + to compare AGAINST that never arrived - so the comparison on screen is still the + old one. It said nothing at all before this, which is the worst of the three: the + spinner stopped and the panel looked finished. Same shape and same Dismiss as the + snapshot report rather than a second style of error surface, because it is the same + kind of statement - something you asked for was not done, spent only by you. */} + {remoteFailure !== null && ( +
+ + + {`Nothing was fetched from ${remoteFailure.connectionName}, so the comparison on screen is not that database: ${remoteFailure.reason}`} + + +
+ )} + {/* Content */}
{!targetId ? ( @@ -448,13 +929,26 @@ export function SchemaDiff({ schema, connection }: SchemaDiffProps) { snapshots={snapshots} onCompare={(sourceId, targetId) => { setSourceId(sourceId); - setTargetId(targetId); + chooseTarget(targetId); }} onDelete={deleteSnapshot} />
)}
+ ) : missingSnapshotId ? ( + /* The snapshot a side names is not in the store any more. What stood here was a + full diff computed against an empty array - the whole database reported as + removed - which is the loudest thing this panel can say and was not true. It + says what is actually the matter instead, and offers the only move there is: + pick something else. */ +
+ + {"This snapshot is no longer stored, so there is nothing to compare"} + + {"Only the last 50 snapshots are kept, and another tab may have deleted it. Choose a different one."} + +
) : showMigration && migrationSQL ? (
diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx
index aaa7ef008..eb04b8309 100644
--- a/src/components/Studio.tsx
+++ b/src/components/Studio.tsx
@@ -409,6 +409,7 @@ export default function Studio() {
     activeConnection: conn.activeConnection,
     currentTab: tabMgr.currentTab,
     executeQuery: queryExec.executeQuery,
+    transactionActive: txn.transactionActive,
   });
 
   // Inline row editing is offered only where the provider declares the row-update
diff --git a/src/hooks/use-inline-editing.ts b/src/hooks/use-inline-editing.ts
index 9fa5efdc6..80446297f 100644
--- a/src/hooks/use-inline-editing.ts
+++ b/src/hooks/use-inline-editing.ts
@@ -1,16 +1,30 @@
 "use client";
 
 import { useCallback, useState } from "react";
-import type { DatabaseConnection, QueryTab } from "@/lib/types";
+import type { DatabaseConnection, QueryResult, QueryTab } from "@/lib/types";
 import type { CellChange } from "@/components/ResultsGrid";
 import { useToast } from "@/hooks/use-toast";
 import { quoteIdentifier } from "@/lib/sql/identifier";
-import { resolveUpdateTarget } from "@/lib/sql/update-target";
+import { resolveUpdateTarget, selectsPlainColumn } from "@/lib/sql/update-target";
 import { positionalPlaceholder, quoteLiteral } from "@/lib/sql/values";
+import { appFetch } from "@/lib/config/base-path";
+import { buildConnectionPayload } from "@/hooks/use-connection-payload";
+import { asBytes } from "@/lib/export/binary";
 
 interface UseInlineEditingParams {
   activeConnection: DatabaseConnection | null;
   currentTab: QueryTab;
+  /**
+   * Whether a transaction is open on this connection.
+   *
+   * The UPDATEs already follow it: `executeQuery` sends them to `/api/db/transaction`,
+   * which holds the one reserved connection the transaction lives on. The key check has to
+   * follow it too, or it asks a different pooled connection and cannot see anything the
+   * transaction has not committed. Measured: a row INSERTed inside an open transaction is
+   * on screen, invisible to the check, and the apply refuses for ever with "no longer in
+   * the table" - a sentence that is false, about rows the user is looking at.
+   */
+  transactionActive?: boolean;
   /**
    * `useQueryExecution`'s `executeQuery`. `handleApplyChanges` awaits it between
    * rows and passes its execution options, so the signature carries both.
@@ -34,7 +48,538 @@ const rows = (count: number) => `${count} row${count === 1 ? "" : "s"}`;
 /** The same, for the statements a run is made of. */
 const updates = (count: number) => `${count} UPDATE statement${count === 1 ? "" : "s"}`;
 
-export function useInlineEditing({ activeConnection, currentTab, executeQuery }: UseInlineEditingParams) {
+/**
+ * What the ENGINE said this key column is, in the one distinction that decides whether a
+ * value read out of it can be sent back as the row that holds it.
+ *
+ * - `date-time`: a column declared as a date or a timestamp.
+ * - `float64`: a column declared as a 64-bit IEEE float, the one width a JavaScript number
+ *   is. A value read out of such a column IS the value the row holds, exactly.
+ * - `declared`: a column declared as something else. Its values are what they look like.
+ * - `undeclared`: the result carried no type for this column at all.
+ */
+type KeyColumnKind = "date-time" | "float64" | "declared" | "undeclared";
+
+/**
+ * The declared types whose value is an INSTANT, matched on the type's own first word.
+ *
+ * Every driver here spells the type the way its engine's catalog does, so the set is the
+ * union of those spellings and nothing else: `timestamp without time zone` and `timestamp
+ * with time zone` (`pg`), `datetime`/`timestamp`/`date` (mysql2), `DATE` and `TIMESTAMP
+ * WITH TIME ZONE` (oracledb, uppercase), `datetime2`/`smalldatetime`/`datetimeoffset`
+ * (mssql), `DateTime64(6, 'UTC')` and `Date32` (ClickHouse), `timestamp(3) with time zone`
+ * (Trino), `DATETIME` (a SQLite/libsql decltype).
+ *
+ * The FIRST WORD of the type and not a substring of it, which is the whole reason this is
+ * a set rather than a `.includes("date")`: PostgreSQL's `daterange`, `tsrange` and
+ * `tstzrange` are ranges rendered as text, `'[2024-01-01,2024-02-01)'`, whose text is
+ * exactly their identity — a substring test would refuse three perfectly good keys to
+ * catch one bad one.
+ *
+ * `time` and `time with time zone` are deliberately ABSENT. `pg` and `mysql2` both hand a
+ * time-of-day back as the string the engine prints (`'10:00:00'`), which is its own
+ * identity and matches when sent back, so refusing one would take away a key that works.
+ * A date or a timestamp is the opposite: those same two drivers hand back a JavaScript
+ * `Date`, and that is the value this whole check exists for.
+ */
+const INSTANT_TYPE_NAMES: ReadonlySet = new Set([
+  "date",
+  "date32",
+  "datetime",
+  "datetime2",
+  "datetime64",
+  "datetimeoffset",
+  "smalldatetime",
+  "timestamp",
+  "timestamptz",
+]);
+
+/**
+ * The declared types that ARE a 64-bit IEEE float, matched on the type's own first word.
+ *
+ * A JavaScript number is a 64-bit IEEE float, and `String()` of one is the shortest decimal
+ * that parses back to the same double — so a value read out of a column of this width is
+ * the value the row holds, and the decimal sent back is read by the engine as that same
+ * value. MEASURED 2026-09-18: PostgreSQL 16.15 `double precision` and MySQL 8.4.11 `DOUBLE`
+ * both answered one row for every one of 0.30000000000000004, 1.5, 0.1, 1e-7 and 1e21 sent
+ * back through this hook's own bound-parameter path.
+ *
+ * The spellings are the engines' own, which is what `QueryResult.columnTypes` carries:
+ * `double precision` (`pg`, first word `double`), `double` (mysql2, mssql's `float` is NOT
+ * here — see below), `Float64` (ClickHouse), `BINARY_DOUBLE` (oracledb, uppercase),
+ * `double` (Trino, DuckDB).
+ *
+ * TWO WORDS ARE DELIBERATELY ABSENT, because one word means two widths across engines and
+ * a key that is refused is only inconvenient while a key that is waved through is wrong:
+ *
+ * - `float`. MySQL's `FLOAT` is 32 bits; T-SQL's `float` is 64. MEASURED on MySQL 8.4.11,
+ *   `zz40_f(k FLOAT)` holding 0.1: mysql2's text protocol hands over the double 0.1, and
+ *   `WHERE k IN (0.1)` matched NOTHING — the row holds the 32-bit 0.100000001490116…, and
+ *   0.1 is a different number to it. So `float` falls through to the digit rule below.
+ * - `real`. PostgreSQL's and Trino's are 32 bits; SQLite's is 64. (PostgreSQL happens to
+ *   match a `real` key anyway — it infers the parameter's type from the column and re-reads
+ *   the decimal at 32-bit precision — but that is `pg`'s doing, not the value's.)
+ *
+ * Which is why the two words are absent from THIS set and not from the rule: on an engine
+ * that has no 32-bit float at all they mean 64 bits and nothing else, and that is what
+ * `FLOAT64_ONLY_NAMES` below adds back, for those engines only.
+ */
+const FLOAT64_TYPE_NAMES: ReadonlySet = new Set(["double", "float8", "float64", "binary_double"]);
+
+/**
+ * The engines with only ONE float width, and the words that therefore state it there.
+ *
+ * SQLite has no 32-bit float: `REAL`, `FLOAT`, `DOUBLE` and `DOUBLE PRECISION` are four
+ * spellings of one storage class, 8 bytes of IEEE double, which is exactly as wide as the
+ * JavaScript number in front of us. So on these dialects the declaration DOES say the
+ * width, and the refusal's sentence — "nothing here says the column holds it as a 64-bit
+ * float" — is false about them.
+ *
+ * MEASURED 2026-09-18 through the real provider path, on bun:sqlite (Bun 1.4.0) and on
+ * libSQL server v0.24.33 over its HTTP pipeline: `zz_real(r REAL, f FLOAT, d DOUBLE, dp
+ * DOUBLE PRECISION)` reported those four decltypes and answered `real` to `typeof()` for
+ * every one of them; 0.30000000000000004 was written and read back identical, which no
+ * 32-bit column can do; and `WHERE r = 1.5`, `WHERE f = 0.1`, `WHERE d =
+ * 0.30000000000000004` and `WHERE dp = 0.1` each matched exactly one row on both.
+ *
+ * `libsql` is here for the reason `sqlite` is: it embeds the same engine and reports the
+ * same `sqlite3_column_decltype` declarations.
+ *
+ * NO OTHER DIALECT IS, and each was measured rather than assumed. PostgreSQL 16.15:
+ * `real` and `float4` are both spelled `real` by `pg`, both `pg_column_size` 4, and
+ * `0.1::real::float8` is 0.10000000149011612 — a different number to the double 0.1.
+ * MySQL 8.4.11: one row holding 0.1 in a `FLOAT` and a `DOUBLE` answered `f = 0.1` FALSE
+ * and `d = 0.1` TRUE. DuckDB: `REAL` and `FLOAT` are one 32-bit type, handed over as
+ * 0.10000000149011612, and `r::DOUBLE = 0.1` is false. Trino was not reachable to measure,
+ * so it keeps the refusal — the closed side is the safe side, which is what this rule
+ * already chose.
+ */
+const FLOAT64_ONLY_DIALECTS: ReadonlySet = new Set(["sqlite", "libsql"]);
+
+/** The words that mean 64 bits ONLY on the dialects above, and 32 elsewhere. */
+const FLOAT64_ONLY_NAMES: ReadonlySet = new Set(["real", "float"]);
+
+/**
+ * Reads one declared type down to its first word.
+ *
+ * ClickHouse spells a nullable or dictionary-encoded column by WRAPPING the real type —
+ * `Nullable(DateTime64(6, 'UTC'))`, `LowCardinality(Nullable(String))` — so the wrappers
+ * come off first, and only then the parameters: `DateTime64(6, 'UTC')` is `datetime64`,
+ * Trino's `timestamp(3) with time zone` is `timestamp`, and `character varying` is
+ * `character`, which is in no set here.
+ *
+ * The DIALECT is read alongside the word, because one word is two widths across engines:
+ * `REAL` is 64 bits on SQLite and 32 on PostgreSQL, and the same declaration therefore
+ * settles the question on one and settles nothing on the other.
+ */
+function keyColumnKind(declaredType: string | undefined, dialect: DatabaseConnection["type"]): KeyColumnKind {
+  if (declaredType === undefined) return "undeclared";
+  let name = declaredType.trim().toLowerCase();
+  for (;;) {
+    // Each turn strips a whole `wrapper(` and its `)`, so the name shortens every time and
+    // this cannot spin on a type it fails to unwrap.
+    const wrapper = /^(?:nullable|lowcardinality)\((.*)\)$/.exec(name);
+    if (wrapper === null) break;
+    name = wrapper[1].trim();
+  }
+  const first = name.split("(")[0].trim().split(/\s+/)[0];
+  if (INSTANT_TYPE_NAMES.has(first)) return "date-time";
+  if (FLOAT64_TYPE_NAMES.has(first)) return "float64";
+  return FLOAT64_ONLY_DIALECTS.has(dialect) && FLOAT64_ONLY_NAMES.has(first) ? "float64" : "declared";
+}
+
+/**
+ * The one refused kind whose reason is not that the value is mangled on the way out.
+ *
+ * Every other kind here — binary data, a date, a document — is measurably a different value
+ * by the time `String()` has had it. A fractional number is not: its decimal is exact, and
+ * what is missing is any statement of the WIDTH the engine will read it back at. Saying it
+ * "does not reach the table as the row holds it" was false about it — measured on
+ * PostgreSQL 16.15, 0.30000000000000004 in a `double precision` column reaches the table
+ * exactly, and `WHERE f_id = 0.30000000000000004` found the row — so it gets its own reason.
+ */
+const FRACTIONAL = "a fractional number";
+
+/**
+ * The most significant decimal digits any 32-bit IEEE float needs to round-trip — nine.
+ *
+ * So a decimal spelling LONGER than this cannot be the printed form of a 32-bit float, and
+ * the value in front of us can only have come from a 64-bit one. That is the whole of the
+ * inference below, and it is a bound rather than a guess: `String()` of a JavaScript number
+ * is the SHORTEST decimal that parses back to it, and every 32-bit float has a shortest
+ * form of nine digits or fewer.
+ */
+const FLOAT32_MAX_DIGITS = 9;
+
+/** How many significant digits `String(value)` spends — `0.1` one, `100.5` four. */
+function significantDigits(value: number): number {
+  const mantissa = Math.abs(value).toString().split("e")[0];
+  return mantissa.replace(".", "").replace(/^0+/, "").length;
+}
+
+/**
+ * Whether a fractional number can be sent back as the row that holds it.
+ *
+ * The decimal `String()` writes always parses back to the same double — that is what
+ * "shortest round-trip" means — so the only question is at what precision the ENGINE reads
+ * it. Where it reads at 64 bits the answer is the same number and the row is found; where
+ * the column is narrower, the number we hold is a rendering of a value the engine will not
+ * agree with, and asking about it matches nothing.
+ *
+ * MEASURED 2026-09-18, and both halves are needed:
+ *  - The declaration settles it where there is one. PostgreSQL 16.15 `double precision` and
+ *    MySQL 8.4.11 `DOUBLE` matched every fractional key put to them, 1.5 and
+ *    0.30000000000000004 alike.
+ *  - Where there is none, the LENGTH of the decimal still settles half of it. MySQL's
+ *    binary protocol hands a `FLOAT` over as the 17-digit 0.10000000149011612, and sending
+ *    that back matched; its text protocol hands the same column over as 0.1, and sending
+ *    THAT back matched nothing. Nine digits or fewer is a spelling a 32-bit column can
+ *    produce, so it is refused; more than nine is one no 32-bit column can produce.
+ *
+ * Fail closed, in other words: allowed only where the value is provably the one the row
+ * holds. A `numeric`/`DECIMAL` never reaches here at all — `pg` and `mysql2` both hand those
+ * back as STRINGS, exactly so nothing rounds them — and a driver configured to hand one back
+ * as a number lands in the refused half unless its digits prove otherwise.
+ */
+function carriesFraction(value: number, column: KeyColumnKind): boolean {
+  return column === "float64" || significantDigits(value) > FLOAT32_MAX_DIGITS;
+}
+
+/**
+ * Whether a string is EXACTLY what a `Date` turns into on its way through JSON.
+ *
+ * `JSON.stringify` calls `Date.prototype.toJSON`, which is `toISOString`, which always
+ * writes four-to-six year digits, always three fractional digits and always a literal `Z`
+ * — so `2026-01-01T07:00:00.123Z` is that and `2024-01-15T10:30:00Z` (no fraction) is not,
+ * nor is `2024-01-15`, nor `2024-01-15 10:30:00`. The re-serialisation is what makes it
+ * exact rather than approximate: a string in the right shape that no `Date` would ever
+ * produce, `2026-02-30T00:00:00.000Z`, comes back as `2026-03-02T...` and is let through.
+ *
+ * This is the LAST resort and not the rule. It runs only where the result declared no type
+ * for the key column at all, because a shape is evidence about a value and a declaration is
+ * a fact about the column: a `text` column really holding `2026-01-01T07:00:00.123Z` is
+ * settled by its declaration and never reaches this.
+ */
+function isSerializedDate(value: string): boolean {
+  if (!/^[+-]?\d{4,6}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
+  const parsed = new Date(value);
+  return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
+}
+
+/**
+ * Why this key value cannot be sent back to the engine as the row that holds it — or
+ * `null` when it can.
+ *
+ * Everything downstream of here spells a key ONE way: `typeof key === "number" ? key :
+ * String(key)`. That is faithful for text, for a bigint's digits, for `true`/`false` and
+ * for an integer a double can hold exactly, and for nothing else.
+ *
+ * MEASURED, and the reason this exists. A `bytea` on PostgreSQL 16 and a `BINARY(16)` on
+ * MySQL 8.4 both reach the grid as a Buffer over the library path and as
+ * `{"type":"Buffer","data":[…]}` over the HTTP one, since the response is JSON.
+ * `String()` turns the first into its bytes read as text and the second into
+ * "[object Object]", and the check below then asked the engine about that:
+ * PostgreSQL refused the parameter (`invalid byte sequence for encoding "UTF8": 0x00`),
+ * MySQL accepted it and matched nothing — so the apply answered "some of the rows you
+ * edited are no longer in the table. Run the query again" about a row that `SELECT
+ * count(*)` put at one. The advice is a loop: the same query produces the same Buffer and
+ * the same refusal for ever. A `timestamp` is the same defect with a different value —
+ * `pg` hands back a Date, which has no microseconds to hand back, and `String(date)` has
+ * no fractional seconds at all.
+ *
+ * So the question is asked HERE, before the engine is asked anything, and the sentence
+ * the user gets is about the value in front of them rather than about rows that never
+ * moved.
+ *
+ * An allowlist and not a list of bad shapes: a driver may hand back anything, and a kind
+ * nobody anticipated has to fail closed. The kinds are named individually anyway, because
+ * "binary data" tells the reader which column to stop keying on and "a value" does not.
+ *
+ * AND THE VALUE IS NOT THE ONLY WITNESS. A `Date` is only ever a `Date` where this hook
+ * runs in-process; the browser reads its rows from `/api/db/query`, which is JSON, and
+ * `JSON.stringify(date)` is a STRING. MEASURED 2026-09-18 against PostgreSQL 16.15 and
+ * MySQL 8.4.11 through this hook, with the rows put through `JSON.parse(JSON.stringify())`
+ * exactly as the route delivers them: a `timestamp`, a `timestamptz` and a `DATETIME(6)`
+ * key all arrive as `"2026-01-01T10:00:00.123Z"` — the microseconds gone, and a `Z` on a
+ * value PostgreSQL stores with no zone at all — the string was waved through as its own
+ * text, the engine WAS asked, it matched nothing, and the user was told "some of the rows
+ * you edited are no longer in the table. Run the query again" while `SELECT count(*)`
+ * answered 2. So the check reads the column's DECLARED type, which those same results
+ * carry beside the rows (`QueryResult.columnTypes`) and which says `timestamp without time
+ * zone` / `timestamp with time zone` / `datetime` no matter what shape the value took to
+ * get here.
+ */
+function describeUncarriableKey(value: unknown, column: KeyColumnKind): string | null {
+  // `String()` is these values themselves, and a safe integer goes to the driver as the
+  // number it is — unless the engine declared the column an instant, in which case what
+  // the driver handed over is a rendering of one and not the value the table holds.
+  if (typeof value === "string") {
+    if (column === "date-time") return "a date and time";
+    // No declaration to go on. The shape a `Date` takes through JSON is the one thing left
+    // to read, and it is asked exactly, so a text column holding `2024-01-15T10:30:00Z` —
+    // or a date, or a timestamp anybody typed — is untouched by it.
+    return column === "undeclared" && isSerializedDate(value) ? "a date and time" : null;
+  }
+  if (typeof value === "bigint" || typeof value === "boolean") return column === "date-time" ? "a date and time" : null;
+  if (typeof value === "number") {
+    if (column === "date-time") return "a date and time";
+    if (Number.isSafeInteger(value)) return null;
+    if (!Number.isFinite(value)) return "not a number";
+    if (!Number.isInteger(value)) return carriesFraction(value, column) ? null : FRACTIONAL;
+    // Whole, and past the range a double spells every integer in. WHAT THE COLUMN IS
+    // decides this one, not how big the number is, and the two halves were measured
+    // 2026-09-18 on PostgreSQL 16.15 and MySQL 8.4.11 through this hook's own path:
+    //
+    //  - A column the result declares 64 bits wide is exactly as wide as the number in
+    //    front of us, so 1e21 read out of a `double precision` / `DOUBLE` column IS the
+    //    value the row holds, and `String()` of it is the shortest decimal that parses
+    //    back to that same double. Both engines answered ONE row for 1e21 and for 1e300
+    //    sent back as a bound parameter, and the UPDATE that followed changed that one
+    //    row. Refusing it took away work the engine does perfectly — and said something
+    //    untrue about the data while doing it: nothing is out of range about a double a
+    //    `double precision` column holds exactly.
+    //  - An INTEGER column is the opposite: `mysql2` rounds a BIGINT past 2^53, so
+    //    9007199254740993 arrives as ...992 — the key of the NEIGHBOURING row, which the
+    //    engine would answer about perfectly. Measured on `zz43_big(b_id bigint)` holding
+    //    both keys: `WHERE b_id = 9007199254740992` matched one row, the neighbour's.
+    //
+    // Everything else stays refused, declared or not, because only the declaration says
+    // the number was read at a width that holds it: a bigint's digits are past what a
+    // double spells, and where no type came with the result there is nothing to read.
+    return column === "float64" ? null : "a whole number past the range this editor carries exactly";
+  }
+  // Both shapes a binary cell arrives in, read by the same function the grid renders it
+  // with, so the refusal names what the reader is looking at.
+  if (asBytes(value) !== undefined) return "binary data";
+  // Not `instanceof`: a Date built in another realm — which is every Date a host
+  // application hands the embeddable shell — is still a Date to this.
+  if (Object.prototype.toString.call(value) === "[object Date]") return "a date and time";
+  return "a value with no text form the engine could match";
+}
+
+/** How many of `keys` are unusable in the same way, and which way that is. */
+function uncarriableKeys(
+  keys: readonly unknown[],
+  numbers: boolean,
+  column: KeyColumnKind,
+): { readonly count: number; readonly what: string } | null {
+  const described = keys
+    .filter((key) => (typeof key === "number") === numbers)
+    .map((key) => describeUncarriableKey(key, column))
+    .filter((what): what is string => what !== null);
+  const what = described[0];
+  // Only the rows carrying the SAME kind are counted: a Buffer and a Date in one apply are
+  // two facts, and "2 rows carry binary data" would be false about one of them.
+  return what === undefined ? null : { count: described.filter((other) => other === what).length, what };
+}
+
+/** What the user is told about such a key: what it is, and what to put in the query instead. */
+const uncarriableReason = (keyColumn: string, found: { readonly count: number; readonly what: string }) =>
+  `This editor cannot address a row by ${keyColumn} here: in ${rows(found.count)} you edited it is ${found.what}, ` +
+  (found.what === FRACTIONAL
+    ? "and nothing here says the column holds it as a 64-bit float, so the engine may read that decimal back as a " +
+      "different number"
+    : "which does not reach the table as the row holds it") +
+  ". Put a column that identifies a row as text or a whole number in the query and run it again, or edit the SQL " +
+  "by hand";
+
+/**
+ * Whether the column this editor found actually addresses ONE row per value.
+ *
+ * The key is a GUESS: the first field called `id` or ending in `_id`. On a result that
+ * carries a foreign key and not the table's own key — `SELECT category_id, product_name
+ * FROM products` — the guess lands on `category_id`, and the `UPDATE ... WHERE
+ * category_id = 5` that follows rewrites every product in that category. Measured on
+ * PostgreSQL 16 against the sample data: editing one cell changed FIFTEEN rows, and the
+ * apply reported one statement accepted, so nothing on screen said otherwise. Resolving
+ * the right TABLE (#881) does not help here; this is the right table and the wrong rows.
+ *
+ * One grouped count over the DISTINCT keys about to be written answers it for the whole
+ * apply: every group has to come back holding exactly one row, and there have to be as
+ * many groups as there are distinct keys.
+ *
+ * Distinct is the first word that matters. Counting the keys per ROW lets the defect
+ * straight back through: editing three rows that share `order_id` 87 sends `IN (87, 87,
+ * 87)`, the engine counts the three rows behind that one value, three equals three, and
+ * all three UPDATEs write to all three rows. Measured on the sample data — `order_items`
+ * has a composite key and the guess takes `order_id` — and editing a whole order's lines
+ * is the ordinary thing to do, so this needed no coincidence at all.
+ *
+ * GROUPED is the second, and a plain total would not have caught it: the engine decides
+ * what counts as the same key, not JavaScript. MySQL's default collation is
+ * case-insensitive, so two rows keyed `abc` and `ABC` are two distinct keys here and one
+ * key there. Measured on MySQL 8.4: `IN ('abc', 'ABC')` counts two rows, two equals two,
+ * and `WHERE k = 'abc'` then writes to BOTH. Grouped, the engine answers one group of two
+ * and the apply refuses. The same argument covers trailing spaces on CHAR columns and
+ * every other collation the engine applies and this side cannot see.
+ *
+ * A row that has gone missing is a different fact and gets a different sentence: fewer
+ * rows than keys says nothing about whether the column tells them apart.
+ *
+ * And the rows ON SCREEN have to answer as many keys as there are of them. Two grid rows
+ * that collapse to one key are not told apart by that column either, and this side cannot
+ * always see it: `bun:sqlite` hands back the text `'1'` and the integer `1` from the same
+ * dynamically typed column, and `mysql2` rounds a BIGINT past 2^53, so `9007199254740993`
+ * arrives as `...992` — the same number as its neighbour. Measured on both. In each case
+ * the engine was asked about ONE key, answered one group holding one row, and two UPDATEs
+ * then went out carrying the raw values the grid still held: a row the user never edited
+ * was overwritten and the apply reported success. So the dedup key carries the type as
+ * well as the text, and the number of edited rows has to equal the number of distinct keys.
+ *
+ * Refusing is what a failed check does: this exists to stop a write nobody asked for.
+ */
+async function keyAddressesOneRow(
+  connection: DatabaseConnection,
+  table: string,
+  keyColumn: string,
+  keys: readonly unknown[],
+  inTransaction: boolean,
+  /** What the result said this key column is — `undefined` where it said nothing. */
+  declaredKeyType: string | undefined,
+): Promise<{ ok: true } | { ok: false; reason: string }> {
+  // The connection is already in hand — the same object line 433 reads its dialect from —
+  // so the width a declared float means is read from the engine that declared it.
+  const column = keyColumnKind(declaredKeyType, connection.type);
+  // A key with no value cannot be addressed by `=` at all, and `String(null)` would send
+  // the text "null" — which an integer column rejects, so the whole apply would fail on a
+  // driver error rather than on the reason.
+  if (keys.some((key) => key === null || key === undefined)) {
+    return { ok: false, reason: `a row you edited has no ${keyColumn}, so it cannot be addressed` };
+  }
+  // A value whose TEXT is not its identity is refused BEFORE the dedup below, which reads
+  // keys as text: two different `bytea` values are "[object Object]" to `String()` and the
+  // dedup would collapse them, refusing with "2 rows on screen carry one value between
+  // them" — false, because the grid renders each one's own hex and the reader can see two.
+  const unspellable = uncarriableKeys(keys, false, column);
+  if (unspellable !== null) return { ok: false, reason: uncarriableReason(keyColumn, unspellable) };
+
+  // Safe to read as text: the caller has already refused any key this would throw on, and
+  // every key still here is one whose text is itself.
+  const distinct = [...new Map(keys.map((key) => [`${typeof key}:${String(key)}`, key])).values()];
+  if (distinct.length !== keys.length) {
+    return {
+      ok: false,
+      reason:
+        `This editor cannot tell these rows apart by ${keyColumn}: ${rows(keys.length)} on screen carry ` +
+        `${distinct.length === 1 ? "one value" : `only ${distinct.length} values`} between them. ` +
+        `Put a key that identifies a row in the query and run it again`,
+    };
+  }
+
+  // A number is the one kind whose text IS its identity even when the number itself is
+  // not exact, so it is asked about AFTER the dedup: two BIGINTs the driver rounded to the
+  // same double really do arrive identical and are shown identical, and "2 rows on screen
+  // carry one value between them" is the truer sentence for that. What is left here is a
+  // number that is alone in its inexactness, where nothing else would say anything.
+  const inexact = uncarriableKeys(keys, true, column);
+  if (inexact !== null) return { ok: false, reason: uncarriableReason(keyColumn, inexact) };
+
+  const dialect = connection.type;
+  const params: unknown[] = [];
+  const placeholders = distinct.map((key) => {
+    const placeholder = positionalPlaceholder(dialect, params.length + 1);
+    if (placeholder !== null) {
+      params.push(typeof key === "number" ? key : String(key));
+      return placeholder;
+    }
+    return typeof key === "number" ? String(key) : quoteLiteral(String(key), dialect);
+  });
+  const key = quoteIdentifier(keyColumn, dialect);
+  const sql = `SELECT ${key}, COUNT(*) FROM ${table} WHERE ${key} IN (${placeholders.join(", ")}) GROUP BY ${key}`;
+
+  let data: { rows?: Record[]; error?: string };
+  try {
+    const res = await appFetch(inTransaction ? "/api/db/transaction" : "/api/db/query", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        ...buildConnectionPayload(connection),
+        ...(inTransaction && { action: "query" }),
+        sql,
+        // A limit the answer cannot reach: one group per distinct key, and the keys are the
+        // rows a person edited by hand. Left to the default the answer would be cut at 500
+        // and the missing groups would read as missing ROWS, which is a refusal with a false
+        // reason attached.
+        options: { limit: distinct.length + 1 },
+        ...(params.length > 0 && { params }),
+      }),
+    });
+    // A proxy answering HTML rather than JSON would throw here, and the catch below is
+    // what turns that into a refusal instead of an unhandled rejection.
+    data = await res.json();
+    if (!res.ok) return { ok: false, reason: data.error ?? "the check could not be run" };
+  } catch (err) {
+    return { ok: false, reason: err instanceof Error ? err.message : String(err) };
+  }
+
+  // `/api/db/query` answers rows as objects, always, and the name a bare `COUNT(*)` comes
+  // back under is the engine's business: `count` on PostgreSQL, `COUNT(*)` on MySQL and
+  // SQLite. So the count is read by POSITION — second value of each row, after the key —
+  // rather than by a name no dialect agrees on. PostgreSQL returns it as a STRING, which
+  // is why it goes through `Number`.
+  const groups = data.rows ?? [];
+  const counts = groups.map((row) => Number(Object.values(row)[1]));
+  if (counts.some((count) => !Number.isFinite(count))) {
+    return { ok: false, reason: "the check returned no count" };
+  }
+  const matched = counts.reduce((total, count) => total + count, 0);
+  if (groups.length === distinct.length && counts.every((count) => count === 1)) return { ok: true };
+
+  // Two independent things can be wrong with the same answer, and a refusal that reports
+  // one of them hides the other. MEASURED on PostgreSQL 16: a grid read while `zz_dup_keys`
+  // held k_id 1 twice and k_id 2 once, with the k_id 2 row deleted afterwards, comes back
+  // as ONE group of two rows — so the totals read two-into-two, "the 2 rows you edited
+  // would write to 2 rows" sounds like a rounding error, and the row that has gone is
+  // never mentioned. Both facts are counted separately and both are said.
+  const crowded = counts.filter((count) => count > 1).length;
+  const unanswered = distinct.length - groups.length;
+
+  // No key addresses more than one row, so the column may be perfectly unique and saying
+  // it is not would be false. Fewer rows than keys can only mean rows have gone: a key the
+  // engine folds into another (a case-insensitive collation) still answers for the rows
+  // behind it, so its group would hold more than one and would not be here.
+  if (crowded === 0 && matched < distinct.length) {
+    return { ok: false, reason: "some of the rows you edited are no longer in the table. Run the query again" };
+  }
+
+  // What the engine LITERALLY answered, in both halves. The second one is deliberately not
+  // read as "these rows are gone": an engine that reads two of the keys as one — MySQL's
+  // default collation does — answers with fewer groups than it was asked about while every
+  // row is still there, and "a row is missing" would be false about exactly that case.
+  const short =
+    unanswered > 0
+      ? `, and the engine answered for only ${groups.length} of those ${distinct.length} keys — a row gone since ` +
+        "you read it, or two keys this engine reads as one"
+      : "";
+  return {
+    ok: false,
+    reason:
+      `${keyColumn} does not tell these rows apart in this table: the ${rows(distinct.length)} you edited ` +
+      `would write to ${rows(matched)}${short}. Put the table's own key in the query and run it again`,
+  };
+}
+
+/**
+ * The type the engine declared for one column of this result, or `undefined`.
+ *
+ * `Object.hasOwn` and a `typeof` guard, the way `ResultsGrid` reads the same map: a column
+ * name is arbitrary SQL output, `SELECT 1 AS constructor` is legal, and the map arrives
+ * here through `JSON.parse`, so a plain property read would answer `Object.prototype`'s
+ * own `constructor` — a function — for a column nothing declared.
+ */
+function declaredTypeOf(result: QueryResult, field: string): string | undefined {
+  const types = result.columnTypes;
+  if (types === undefined || !Object.hasOwn(types, field)) return undefined;
+  const declared = types[field];
+  return typeof declared === "string" ? declared : undefined;
+}
+
+export function useInlineEditing({
+  activeConnection,
+  currentTab,
+  executeQuery,
+  transactionActive = false,
+}: UseInlineEditingParams) {
   const [editingEnabled, setEditingEnabled] = useState(false);
   const [pendingChanges, setPendingChanges] = useState([]);
   const { toast } = useToast();
@@ -145,6 +690,26 @@ export function useInlineEditing({ activeConnection, currentTab, executeQuery }:
     const dialect = activeConnection.type;
     const quote = (identifier: string) => quoteIdentifier(identifier, dialect);
 
+    // Every key has to survive being read as text before anything is built from it. A value
+    // with a null prototype has no `toString`, and `String()` throws on it - which happened
+    // where the statement is assembled, so the apply died as an unhandled rejection with no
+    // write and no toast either. Asked here, it is a refusal like any other.
+    const keysByRow = new Map();
+    for (const rowIndex of changesByRow.keys()) {
+      const value = currentTab.result.rows[rowIndex]?.[pkColumn];
+      try {
+        void String(value);
+      } catch {
+        toast({
+          title: "Cannot Apply Changes",
+          description: `This editor cannot read the ${pkColumn} of every row it would write to. Edit the SQL manually.`,
+          variant: "destructive",
+        });
+        return;
+      }
+      keysByRow.set(rowIndex, value);
+    }
+
     // Generate UPDATE statements
     const statements: Array<{ sql: string; params: unknown[]; rowIndex: number }> = [];
     for (const [rowIndex, changes] of changesByRow) {
@@ -188,6 +753,46 @@ export function useInlineEditing({ activeConnection, currentTab, executeQuery }:
       });
     }
 
+    // Before anything is written: is that key column the TABLE's, and does it address one
+    // row per value? The first question comes first because it decides whether the second
+    // one is even being asked about the right thing: a key that is an expression or a
+    // rename sends the check to a real column the grid never showed, and every answer it
+    // gives is about rows nobody is looking at.
+    if (!selectsPlainColumn(currentTab.resultQuery ?? currentTab.query, pkColumn, activeConnection.type)) {
+      toast({
+        title: "Cannot Apply Changes",
+        description: `${pkColumn} is not read straight from the table here, so it cannot identify a row to write to. Edit the SQL manually.`,
+        variant: "destructive",
+      });
+      return;
+    }
+
+    // And then: does that key column address one row per value?
+    // `pkColumn` is a guess off the field list, and on a result carrying a foreign key
+    // rather than the table's own key it aims at the foreign key — one cell edit then
+    // rewrote fifteen rows, reported as one statement accepted.
+    const uniqueness = await keyAddressesOneRow(
+      activeConnection,
+      tableName,
+      pkColumn,
+      // The RAW cell values. Converting here would turn a missing key into the text
+      // "null" before the check could see it was missing.
+      statements.map((statement) => keysByRow.get(statement.rowIndex)),
+      transactionActive,
+      // What the engine itself called this column, carried beside the rows by the same
+      // response that carried them. It is the only witness that survives the trip through
+      // JSON, which is what turns a `Date` into a string indistinguishable from text.
+      declaredTypeOf(currentTab.result, pkColumn),
+    );
+    if (!uniqueness.ok) {
+      toast({
+        title: "Cannot Apply Changes",
+        description: `${uniqueness.reason}.`,
+        variant: "destructive",
+      });
+      return;
+    }
+
     // One request per row (issue #269), sequentially and with the safety dialog
     // skipped. Each part matters:
     //  - per row, because a joined payload reaches the engine as ONE string whenever
@@ -273,7 +878,7 @@ export function useInlineEditing({ activeConnection, currentTab, executeQuery }:
           ? `${updates(statements.length)} accepted. Run the query again to see the saved rows.`
           : `${updates(statements.length)} accepted. The results are up to date.`,
     });
-  }, [activeConnection, currentTab, pendingChanges, executeQuery, toast]);
+  }, [activeConnection, currentTab, pendingChanges, executeQuery, toast, transactionActive]);
 
   const handleDiscardChanges = useCallback(() => {
     setPendingChanges([]);
diff --git a/src/hooks/use-read-generation.ts b/src/hooks/use-read-generation.ts
index 756c5edb3..5dce0e07f 100644
--- a/src/hooks/use-read-generation.ts
+++ b/src/hooks/use-read-generation.ts
@@ -17,10 +17,11 @@ import { useMemo, useRef } from "react";
  * deferred connection issues no request at all and still supersedes a read in flight, which
  * is what `supersede()` is for. What is being sequenced is the READ, not the socket.
  *
- * Stated once and used twice, deliberately. `src/hooks/use-connection-manager.ts` reads this
- * application's own routes and `src/workspace/hooks/use-connection-adapter.ts` calls back
- * into an embedded host whose latency is not ours to bound, and the embedded one shipped
- * without the guard because the rule lived inside the other hook rather than beside both.
+ * Stated once and used by three, deliberately. `src/hooks/use-connection-manager.ts` reads
+ * this application's own routes, `src/workspace/hooks/use-connection-adapter.ts` calls back
+ * into an embedded host whose latency is not ours to bound, and `src/components/SchemaDiff.tsx`
+ * has three reads of its own that can be in flight at once. The embedded one shipped without
+ * the guard because the rule lived inside the other hook rather than beside all of them.
  */
 export interface ReadGeneration {
   /**
diff --git a/src/lib/db/providers/sql/libsql/hrana-transport.ts b/src/lib/db/providers/sql/libsql/hrana-transport.ts
index f92d338c9..73ab4d8b2 100644
--- a/src/lib/db/providers/sql/libsql/hrana-transport.ts
+++ b/src/lib/db/providers/sql/libsql/hrana-transport.ts
@@ -198,6 +198,82 @@ function decodeValue(raw: unknown): unknown {
   }
 }
 
+/**
+ * Sending one back (#44)
+ * ----------------------
+ * `decodeInteger` above is lossy in ONE direction that matters: `9007199254740993`
+ * the integer and `'9007199254740993'` the text both leave this transport as the
+ * same JavaScript string, so a value arriving in a bind carries no clue which it
+ * was. SQLite settles it by the COLUMN's affinity, and only for a column that HAS
+ * one: measured 2026-09-18 against sqld 0.24.33
+ * (`ghcr.io/tursodatabase/libsql-server:v0.24.33`, SQLite 3.47.0), on a row whose
+ * key is 9007199254740993 -
+ *
+ *   column declared   | bound as text | bound as an integer
+ *   INTEGER / NUMERIC |       matches |             matches
+ *   TEXT              |       matches |             matches
+ *   BLOB / undeclared |   NO MATCH    |             matches
+ *
+ * INTEGER and NUMERIC affinity convert the text to a number before comparing and
+ * TEXT affinity converts the integer to text, so those answer the same either way.
+ * A column declared BLOB or declared NOTHING has NO affinity: SQLite compares the
+ * operands as they stand, a text is never equal to an integer, and the row the grid
+ * just read cannot be found again - `UPDATE ... WHERE id = ?` reports 0 rows changed
+ * and the editor tells the user nothing happened. Measured here before the fix:
+ * INTEGER 1 row, TEXT 1 row, BLOB 0 rows, undeclared 0 rows.
+ *
+ * Note what this is NOT: unlike bun:sqlite, the libSQL read side never rounds - the
+ * neighbouring row is never edited on either side of this change. Hrana quotes its
+ * integers, so the damage here is a silent no-op, not a wrong write.
+ *
+ * The affinity is not knowable here - a bind is a value, with no column attached,
+ * and the protocol never names the column an operand belongs to - so the seam
+ * answers the question it CAN answer exactly: it accepts back precisely what it
+ * handed out. `decodeInteger` emits these digits for one input only, a 64-bit
+ * integer outside the safe range, so reading them back as that integer is its exact
+ * inverse and every other string is left alone:
+ *
+ * - inside the safe range (`'1'`, `'9007199254740991'`) the read hands out a NUMBER,
+ *   never digits, so such a string is the caller's own text;
+ * - `'007'`, `'+7'`, `''`, `' 7'`, `'7.0'`, `'9e15'` are not shapes it can emit;
+ * - wider than 64 bits (`'99999999999999999999'`) is not a value SQLite's INTEGER
+ *   can hold, so no row could match it as a number either.
+ *
+ * What that costs, measured and accepted: in a column with NO affinity that
+ * genuinely stores this shape as TEXT, the bind now misses where it used to match.
+ * That is the same ambiguity read from the other end, it cannot be resolved without
+ * the affinity, and the integer reading is the one these digits exist for. A
+ * TEXT-declared column is NOT affected - TEXT affinity converts the bind back to
+ * text - so an ordinary textual key still matches as text.
+ *
+ * This is the same rule `toSQLiteBindValue` applies in the SQLite driver (#42), by
+ * design: the two providers hand out the same shape, so they must accept the same
+ * shape back.
+ */
+
+/** SQLite's own INTEGER: signed 64-bit, and nothing wider can be stored in a row. */
+const MAX_INT64_BIGINT = BigInt("9223372036854775807");
+const MIN_INT64_BIGINT = BigInt("-9223372036854775808");
+const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
+const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
+
+/**
+ * The exact shape `decodeInteger` prints: an optional minus, a non-zero first digit,
+ * at most 19 digits in all (INT64's own width). Leading zeros, a leading `+`,
+ * surrounding space, a decimal point, exponent form and the empty string all fall
+ * outside it.
+ */
+const HRANA_INT64_DIGITS = /^-?[1-9][0-9]{0,18}$/;
+
+/** Whether these digits are ones `decodeInteger` could itself have handed out. */
+function isDecodedInteger(param: string): boolean {
+  if (!HRANA_INT64_DIGITS.test(param)) return false;
+  const parsed = BigInt(param);
+  // Inside the safe range the read hands out a number, so digits are the caller's text.
+  if (parsed >= MIN_SAFE_BIGINT && parsed <= MAX_SAFE_BIGINT) return false;
+  return parsed >= MIN_INT64_BIGINT && parsed <= MAX_INT64_BIGINT;
+}
+
 /**
  * One JavaScript parameter as a wire value.
  *
@@ -205,7 +281,8 @@ function decodeValue(raw: unknown): unknown {
  * the reason `decodeInteger` states in the other direction. A boolean becomes 1
  * or 0 because that is what SQLite stores - it has no boolean type - and a `Date`
  * becomes an ISO 8601 string because that is the only form SQLite's own date
- * functions read.
+ * functions read. A STRING carrying the digits of a past-2^53 integer goes back as
+ * the integer it was read as, for the reason above; every other string is text.
  */
 function encodeValue(param: unknown): HranaValue {
   if (param === null || param === undefined) return { type: "null" };
@@ -216,6 +293,9 @@ function encodeValue(param: unknown): HranaValue {
   }
   if (param instanceof Uint8Array) return { type: "blob", base64: Buffer.from(param).toString("base64") };
   if (param instanceof Date) return { type: "text", value: param.toISOString() };
+  // Only a real string, never `String(param)` of some other object: the read side
+  // hands out strings and nothing else, so nothing else can be a value it emitted.
+  if (typeof param === "string" && isDecodedInteger(param)) return { type: "integer", value: param };
   return { type: "text", value: String(param) };
 }
 
diff --git a/src/lib/db/providers/sql/mysql.ts b/src/lib/db/providers/sql/mysql.ts
index d42549aba..9e588eb7f 100644
--- a/src/lib/db/providers/sql/mysql.ts
+++ b/src/lib/db/providers/sql/mysql.ts
@@ -1972,6 +1972,21 @@ export class MySQLProvider extends SQLBaseProvider {
 
   private buildPoolConfig(): mysql.PoolOptions {
     const baseConfig: mysql.PoolOptions = {
+      // Without this, mysql2 hands a BIGINT past 2^53 back as a rounded Number. Measured on
+      // MySQL 8.4.11 through the inline-edit hook: a table holding 9007199254740992 and
+      // 9007199254740993 sent BOTH rows to the browser as ...992, the guard asked about
+      // ...992 and was told one row matched, and the UPDATE then wrote the NEIGHBOUR's row
+      // and reported success. With it, the driver returns a string for the values a Number
+      // cannot hold and the edit writes the row the user opened.
+      //
+      // First entry so it covers both paths below - the structured config and the pasted
+      // connection string, which share nothing else.
+      //
+      // Nothing narrower changes type - measured on the same server with this on, `SELECT 5`
+      // is still the number 5 and `COUNT(*)` is still a number; only the values a Number
+      // cannot hold arrive as strings. `bigNumberStrings` is deliberately NOT set: it would
+      // turn both of those into strings too, changing types that were never wrong.
+      supportBigNumbers: true,
       connectionLimit: this.poolConfig.max,
       waitForConnections: true,
       queueLimit: 0,
diff --git a/src/lib/db/providers/sql/sqlite-driver.ts b/src/lib/db/providers/sql/sqlite-driver.ts
index 2812b4d51..65022ae65 100644
--- a/src/lib/db/providers/sql/sqlite-driver.ts
+++ b/src/lib/db/providers/sql/sqlite-driver.ts
@@ -20,11 +20,48 @@
 
 import { DatabaseConfigError } from "../../errors";
 
+/**
+ * One result column's name and the type it was DECLARED with — `undefined` where SQLite
+ * declared none.
+ *
+ * A PAIR rather than a map, because that is exactly what `declaredColumnTypes()` in
+ * `column-types.ts` already consumes from the four other drivers that answer this
+ * question, duplicate column names and all: `SELECT 1 AS c, name AS c` really does
+ * declare two columns called `c`, the row object keeps the last one, and the shared
+ * helper is where last-wins is decided.
+ */
+export type SQLiteDeclaredColumn = readonly [name: string, declaredType: string | undefined];
+
 // The exact driver surface the SQLite provider uses (bun:sqlite-shaped).
 export type SQLiteStatement = {
   all(...params: unknown[]): unknown[];
   get(...params: unknown[]): unknown;
   run(...params: unknown[]): { changes: number };
+  /**
+   * What the schema DECLARED each result column to be (`sqlite3_column_decltype`), in
+   * column order. Both drivers publish it and spell it differently — bun:sqlite
+   * `columnNames` beside `declaredTypes`, node:sqlite one `columns()` answering both — so
+   * the two are bridged here, exactly as `inTransaction` and the big-integer flag are.
+   *
+   * CALL IT AFTER THE ROWS. Measured 2026-09-18 on bun:sqlite (Bun 1.4.0) and node:sqlite
+   * (Node 24.14.0): bun THROWS "Statement must be executed before accessing declaredTypes"
+   * until the statement has run, while node answers either way — so after the rows is the
+   * one order both drivers accept. A statement that matched NO rows still answers
+   * (`SELECT id, r FROM t WHERE id = -1` → `INTEGER`, `REAL`), so an empty result is
+   * described rather than guessed at, and a write answers an EMPTY list on both.
+   *
+   * `undefined` is an ordinary answer and not a failure: an expression, a literal, an
+   * aggregate, a function call, every PRAGMA column and a column declared with no type at
+   * all have no declaration for SQLite to report, and both drivers say so with `null`.
+   *
+   * NOT bun:sqlite's `columnTypes`, which is a different question wearing a similar name.
+   * Measured the same day: it reports the RUNTIME storage class of the row just read — a
+   * `REAL` column answers `FLOAT`, an undeclared column answers whatever that row happens
+   * to hold — and it throws outright on anything that is not a read-only statement,
+   * `PRAGMA journal_mode` included. Reading it here would have typed every float column
+   * wrong and broken every PRAGMA the provider runs.
+   */
+  declaredColumns(): readonly SQLiteDeclaredColumn[];
 };
 
 export type SQLiteDatabase = {
@@ -75,12 +112,289 @@ export type SQLiteConstructor = new (path: string, options?: SQLiteOpenOptions)
 
 export type SQLiteDriverName = "bun" | "node";
 
+// ============================================================================
+// Big integers at the provider boundary (#39)
+// ============================================================================
+
+/**
+ * SQLite stores INTEGER as a signed 64-bit value, so an id past 2^53 does not
+ * survive a JavaScript `number`. Measured 2026-09-18, reading 9007199254740993
+ * back through this provider with each driver's defaults:
+ *
+ * - bun:sqlite (what the Docker image runs) silently answers 9007199254740992 -
+ *   the row NEXT to the one that was asked for. The inline editor then builds its
+ *   UPDATE ... WHERE id =  and edits the neighbouring row.
+ * - node:sqlite (npx / brew / deb installs) throws ERR_OUT_OF_RANGE instead:
+ *   loud, and no wrong write.
+ *
+ * Both drivers can hand back every integer as a BigInt instead, and each spells
+ * the flag its own way - bun `safeIntegers`, node `readBigInts`. The flag alone is
+ * not a fix, because it is all-or-nothing: `1`, `COUNT(*)` and every PRAGMA
+ * column become BigInt too, rows are serialized to the browser with
+ * JSON.stringify, and JSON.stringify refuses BigInt outright ("cannot serialize
+ * BigInt") - measured, that alone turns 180 passing SQLite tests into 149 passing
+ * and 31 failing, 16 of them connections that will not even open.
+ *
+ * So the flag is turned on for BOTH drivers and the BigInt is converted back
+ * HERE, at the one seam every row crosses:
+ *
+ * - a value that fits a JavaScript number exactly comes back AS a number, so `1`
+ *   stays `1`, COUNT(*) stays a number and PRAGMA columns are unchanged;
+ * - a value that does not fit comes back as its decimal STRING, every digit kept.
+ *
+ * That is exactly what `supportBigNumbers` already does on the MySQL side, so the
+ * two providers now answer the same shape. Nothing outside this module ever sees
+ * a BigInt.
+ */
+const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
+const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
+
+/** One 64-bit integer, as a number when that is lossless and as digits when it is not. */
+export function normalizeSQLiteBigInt(value: bigint): number | string {
+  return value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT ? Number(value) : value.toString();
+}
+
+/**
+ * Sending one back (#42)
+ * ----------------------
+ * The conversion above is lossy in ONE direction that matters: `9007199254740993`
+ * the integer and `'9007199254740993'` the text both leave this provider as the same
+ * JavaScript string, so a value coming back in a bind carries no clue which it was.
+ * SQLite settles it by the COLUMN's affinity, and only for a column that HAS one:
+ * measured 2026-09-18 on bun:sqlite (Bun 1.4.0) and node:sqlite (Node 24), against a
+ * row whose key is 9007199254740993 -
+ *
+ *   column declared   | bound as text | bound as a 64-bit integer
+ *   INTEGER / NUMERIC |       matches |                   matches
+ *   TEXT              |       matches |                   matches
+ *   BLOB / undeclared |   NO MATCH    |                   matches
+ *
+ * INTEGER and NUMERIC affinity convert the text to a number before comparing, and
+ * TEXT affinity converts the integer to text, so those three columns answer the same
+ * either way. A column declared BLOB or declared NOTHING has NO affinity: SQLite
+ * compares the operands as they stand, a text is never equal to an integer, and the
+ * row the grid just read cannot be found again - `UPDATE ... WHERE id = ?` reports 0
+ * rows changed and the editor tells the user nothing happened.
+ *
+ * The affinity is not knowable here - a bind is a value, with no column attached, and
+ * neither driver exposes which column an operand belongs to - so the seam answers the
+ * question it CAN answer exactly: it accepts back precisely what it handed out.
+ * `normalizeSQLiteBigInt` emits these digits for one input only, a 64-bit integer
+ * outside the safe range, so reading them back as that integer is its exact inverse
+ * and every other string is left alone:
+ *
+ * - inside the safe range (`'1'`, `'9007199254740991'`) the read hands out a NUMBER,
+ *   never digits, so such a string is the caller's own text;
+ * - `'007'`, `'+7'`, `''`, `' 7'`, `'7.0'` are not shapes it can emit at all;
+ * - wider than 64 bits (`'99999999999999999999'`) is not a value SQLite's INTEGER can
+ *   hold, so no row could match it as a number either.
+ *
+ * What that costs, measured and accepted: in a column with NO affinity that genuinely
+ * stores this shape as TEXT, the bind now misses where it used to match. That is the
+ * same ambiguity read from the other end, it cannot be resolved without the affinity,
+ * and the integer reading is the one these digits exist for. A TEXT-declared column is
+ * NOT affected - TEXT affinity converts the bind back to text - so an ordinary textual
+ * key still matches as text. Every string function agrees on both forms as well
+ * (measured: `length`, `substr`, `||`, `LIKE`, `lower`, `printf`, `CAST(? AS TEXT)`);
+ * `typeof(?)` and `quote(?)` are the ones that can tell, and they are asking which
+ * storage class it is, which is the question this conversion answers.
+ */
+
+/** SQLite's own INTEGER: signed 64-bit, and nothing wider can be stored in a row. */
+const MAX_INT64_BIGINT = BigInt("9223372036854775807");
+const MIN_INT64_BIGINT = BigInt("-9223372036854775808");
+
+/**
+ * The exact shape `normalizeSQLiteBigInt` prints: an optional minus, a non-zero first
+ * digit, at most 19 digits in all (INT64's own width). Leading zeros, a leading `+`,
+ * surrounding space, a decimal point and the empty string all fall outside it.
+ */
+const SQLITE_INT64_DIGITS = /^-?[1-9][0-9]{0,18}$/;
+
+/** One bound parameter, with the digits of a 64-bit integer read back as that integer. */
+export function toSQLiteBindValue(param: unknown): unknown {
+  if (typeof param !== "string" || !SQLITE_INT64_DIGITS.test(param)) {
+    return param;
+  }
+  const parsed = BigInt(param);
+  if (parsed >= MIN_SAFE_BIGINT && parsed <= MAX_SAFE_BIGINT) {
+    return param;
+  }
+  if (parsed < MIN_INT64_BIGINT || parsed > MAX_INT64_BIGINT) {
+    return param;
+  }
+  return parsed;
+}
+
+/**
+ * Every parameter of one call. Positional only, which is the one shape this provider
+ * binds (`query(sql, params: unknown[])`); a named-parameter OBJECT passes through
+ * untouched rather than being walked, so it keeps the behaviour it has today.
+ */
+function toSQLiteBindValues(params: unknown[]): unknown[] {
+  return params.map(toSQLiteBindValue);
+}
+
+/**
+ * Convert the BigInt cells of one returned record, in place.
+ *
+ * In place on purpose: node:sqlite returns null-prototype row objects and
+ * bun:sqlite returns its own row objects, and rebuilding them would change what
+ * every existing caller receives. Only the BigInt cells change. A BLOB column is
+ * a typed array, never a row, and is left alone rather than walked byte by byte.
+ */
+function normalizeRecordInPlace(record: unknown): unknown {
+  if (record === null || record === undefined) {
+    return record;
+  }
+  if (typeof record === "bigint") {
+    return normalizeSQLiteBigInt(record);
+  }
+  if (typeof record !== "object" || ArrayBuffer.isView(record)) {
+    return record;
+  }
+  const row = record as Record;
+  for (const key of Object.keys(row)) {
+    const value = row[key];
+    if (typeof value === "bigint") {
+      row[key] = normalizeSQLiteBigInt(value);
+    }
+  }
+  return record;
+}
+
+/**
+ * A driver's OWN statement: the three row methods, before the bridge below adds the
+ * declarations. Neither driver publishes `declaredColumns` — it is this module's name for
+ * a question each of them answers its own way.
+ */
+type RawSQLiteStatement = Omit;
+
+/**
+ * Wrap one prepared statement so every row it returns, and every parameter it is given,
+ * crosses the conversions above.
+ *
+ * Both driver adapters below route `prepare()` through this, which is what makes the
+ * coverage argument checkable: the provider reaches the database ONLY through
+ * `SQLiteDatabase`, whose sole row-returning entry point is `prepare()` (`exec()`
+ * returns nothing). The wrapper republishes exactly the three methods of
+ * `SQLiteStatement` and hands back none of the raw driver statement, so a plain
+ * query, a prepared statement, a statement inside a transaction and every schema /
+ * PRAGMA read go through it alike, and a future row-returning driver method cannot
+ * quietly bypass it. The parameters travel the same three methods, so the two
+ * directions are inverses at ONE seam rather than at two that can drift apart.
+ *
+ * `declaredColumns` is handed in rather than read off `stmt`, because it is the one part
+ * of the surface the two drivers do not already spell the same way; each adapter below
+ * passes its own spelling and both come out of here as one method on one object. Keeping
+ * it on the SAME object as the rows is what makes "after the rows" checkable: a caller
+ * holds the statement that produced them and asks it, rather than holding a second handle
+ * whose order nothing constrains.
+ */
+function withoutBigInts(
+  stmt: RawSQLiteStatement,
+  declaredColumns: SQLiteStatement["declaredColumns"],
+): SQLiteStatement {
+  return {
+    declaredColumns,
+    all: (...params: unknown[]): unknown[] => {
+      const rows = stmt.all(...toSQLiteBindValues(params));
+      for (const row of rows) {
+        normalizeRecordInPlace(row);
+      }
+      return rows;
+    },
+    get: (...params: unknown[]): unknown => normalizeRecordInPlace(stmt.get(...toSQLiteBindValues(params))),
+    run: (...params: unknown[]): { changes: number } => {
+      // `changes` is a row count and always fits; `lastInsertRowid` (bun publishes it)
+      // is a rowid and does not have to, so the whole result goes through the same
+      // conversion before anything reads it.
+      const info = normalizeRecordInPlace(stmt.run(...toSQLiteBindValues(params))) as Record & {
+        changes: number | bigint;
+      };
+      return { ...info, changes: Number(info.changes) };
+    },
+  };
+}
+
+/**
+ * bun:sqlite's own open options: the flags the provider passes, plus `safeIntegers`
+ * - bun's spelling of "read 64-bit integers without rounding them". node:sqlite
+ * spells the same request `readBigInts`; the two are bridged here and in
+ * `createNodeSQLiteDriver` below, so the provider keeps passing one set of flags
+ * and no new option reaches any shared surface.
+ */
+export type BunSQLiteOpenOptions = SQLiteOpenOptions & { safeIntegers?: boolean };
+
+/**
+ * Minimal structural view of bun:sqlite's own Statement and Database, kept local for the
+ * reason `NodeDatabaseSyncLike` below is: the adapter then says exactly which of the
+ * driver's members it uses, and a stand-in can satisfy that and nothing more.
+ *
+ * `columnNames` and `declaredTypes` are bun's two halves of the answer node:sqlite gives
+ * in one `columns()` call, and the only reason this type exists at all - the rest of the
+ * surface was already bun-shaped.
+ */
+type BunStatementLike = RawSQLiteStatement & {
+  readonly columnNames: string[];
+  readonly declaredTypes: (string | null)[];
+};
+export type BunDatabaseLike = Omit & { prepare(sql: string): BunStatementLike };
+export type BunSQLiteConstructor = new (path: string, options?: BunSQLiteOpenOptions) => BunDatabaseLike;
+
+/**
+ * Adapt bun:sqlite's Database: open it with `safeIntegers`, and convert what the
+ * flag produces back at `prepare()`. Exported with an injectable constructor for
+ * the same reason `createNodeSQLiteDriver` is - the semantics are then unit-testable
+ * against a stand-in on any runtime.
+ */
+export function createBunSQLiteDriver(DatabaseCtor: BunSQLiteConstructor): SQLiteConstructor {
+  class BunSQLiteDatabase implements SQLiteDatabase {
+    private readonly db: BunDatabaseLike;
+
+    constructor(dbPath: string, options?: SQLiteOpenOptions) {
+      this.db = new DatabaseCtor(dbPath, { ...options, safeIntegers: true });
+    }
+
+    exec(sql: string): void {
+      this.db.exec(sql);
+    }
+
+    prepare(sql: string): SQLiteStatement {
+      const stmt = this.db.prepare(sql);
+      // Read lazily, never here: bun refuses `declaredTypes` until the statement has run
+      // (measured - see `SQLiteStatement.declaredColumns`), so reading it at `prepare()`
+      // would throw on every query the provider makes.
+      return withoutBigInts(stmt, () =>
+        stmt.columnNames.map((name, index) => [name, stmt.declaredTypes[index] ?? undefined] as const),
+      );
+    }
+
+    close(throwOnError?: boolean): void {
+      this.db.close(throwOnError);
+    }
+
+    get inTransaction(): boolean {
+      return this.db.inTransaction;
+    }
+  }
+
+  return BunSQLiteDatabase;
+}
+
 // Minimal structural view of node:sqlite (kept local so the adapter and its
 // tests never need the real module, which Bun does not implement).
 type NodeStatementLike = {
   all(...params: unknown[]): unknown;
   get(...params: unknown[]): unknown;
   run(...params: unknown[]): { changes: number | bigint };
+  /**
+   * node:sqlite's spelling of bun:sqlite's `columnNames` + `declaredTypes`: one call
+   * answering both, with `type` null where the column was declared with none. It carries
+   * `column`, `database` and `table` as well; the bridge reads neither, because the name
+   * the ROW object uses is `name` (the alias, where there is one).
+   */
+  columns(): { name: string; type: string | null }[];
 };
 export type NodeDatabaseSyncLike = {
   exec(sql: string): void;
@@ -90,7 +404,7 @@ export type NodeDatabaseSyncLike = {
   readonly isTransaction: boolean;
 };
 /** node:sqlite's own open options — only the ones this adapter maps. */
-export type NodeSQLiteOpenOptions = { readOnly?: boolean };
+export type NodeSQLiteOpenOptions = { readOnly?: boolean; readBigInts?: boolean };
 export type NodeSQLiteModule = {
   DatabaseSync: new (path: string, options?: NodeSQLiteOpenOptions) => NodeDatabaseSyncLike;
 };
@@ -118,7 +432,7 @@ async function loadBunDriver(): Promise {
   // At runtime nothing changes: Bun resolves its builtin natively, and this
   // branch is only ever taken under the Bun runtime.
   const sqlite = await import(/* turbopackIgnore: true */ /* webpackIgnore: true */ "bun:sqlite");
-  return sqlite.Database as unknown as SQLiteConstructor;
+  return createBunSQLiteDriver(sqlite.Database as unknown as BunSQLiteConstructor);
 }
 
 /**
@@ -137,6 +451,15 @@ async function loadBunDriver(): Promise {
  * - `close(throwOnError)` is bun's spelling of "release the file now". node:sqlite has
  *   no such flag and needs none, so this is the one delta with nothing to bridge; see
  *   the measurement on `SQLiteDatabase.close` above.
+ * - the big-integer flag is `readBigInts` here and `safeIntegers` on bun:sqlite; both
+ *   adapters set their own spelling and both send `prepare()` through the same
+ *   conversion, so the two drivers answer a 64-bit id identically (#39).
+ * - the DECLARED column types are `columns()[].name`/`.type` here and `columnNames` +
+ *   `declaredTypes` on bun:sqlite; both adapters hand their own spelling to
+ *   `withoutBigInts`, which republishes one `declaredColumns()` (#273). A handle that
+ *   dropped it would leave the result carrying no types at all, which is the state this
+ *   provider was in: the SQL export then names a column by the JavaScript type of its
+ *   value, and the inline editor has nothing to read a key's width from.
  * - `get()` returns `undefined` on a miss where bun:sqlite returns `null`.
  * - `run()` reports `changes` as `number | bigint`; normalize to `number`.
  *
@@ -149,7 +472,7 @@ export function createNodeSQLiteDriver(DatabaseSyncCtor: NodeSQLiteModule["Datab
     private readonly db: NodeDatabaseSyncLike;
 
     constructor(dbPath: string, options?: SQLiteOpenOptions) {
-      this.db = new DatabaseSyncCtor(dbPath, { readOnly: options?.readonly === true });
+      this.db = new DatabaseSyncCtor(dbPath, { readOnly: options?.readonly === true, readBigInts: true });
     }
 
     exec(sql: string): void {
@@ -158,14 +481,19 @@ export function createNodeSQLiteDriver(DatabaseSyncCtor: NodeSQLiteModule["Datab
 
     prepare(sql: string): SQLiteStatement {
       const stmt = this.db.prepare(sql);
-      return {
-        all: (...params: unknown[]): unknown[] => stmt.all(...params) as unknown[],
-        get: (...params: unknown[]): unknown => stmt.get(...params) ?? null,
-        run: (...params: unknown[]): { changes: number } => {
-          const info = stmt.run(...params);
-          return { changes: Number(info.changes) };
+      return withoutBigInts(
+        {
+          all: (...params: unknown[]): unknown[] => stmt.all(...params) as unknown[],
+          get: (...params: unknown[]): unknown => stmt.get(...params) ?? null,
+          run: (...params: unknown[]): { changes: number } => {
+            const info = stmt.run(...params);
+            return { changes: Number(info.changes) };
+          },
         },
-      };
+        // node answers this before the statement has run as readily as after it, so the
+        // "after the rows" rule the bun half needs costs this half nothing.
+        () => stmt.columns().map((column) => [column.name, column.type ?? undefined] as const),
+      );
     }
 
     // Takes no `throwOnError`, and needs none: node:sqlite's own close already finalizes
diff --git a/src/lib/db/providers/sql/sqlite.ts b/src/lib/db/providers/sql/sqlite.ts
index cf081d299..8e1c787bb 100644
--- a/src/lib/db/providers/sql/sqlite.ts
+++ b/src/lib/db/providers/sql/sqlite.ts
@@ -49,6 +49,7 @@ import {
 import { assertReadOnlyBudget, measureResultBytes } from "./read-only-budget";
 import { formatBytes } from "../../utils/pool-manager";
 import { loadSQLiteDriver, type SQLiteDatabase } from "./sqlite-driver";
+import { declaredColumnTypes } from "./column-types";
 import {
   applySourceBound,
   callerBoundTruncationReason,
@@ -1254,6 +1255,13 @@ export class SQLiteProvider extends SQLBaseProvider {
               rows: (rows as unknown[]).map((row) => row as Record) as Record[],
               fields,
               changes: 0,
+              // Declared types travel with the result (#273), and are read AFTER the rows
+              // because bun:sqlite refuses the question until the statement has run - see
+              // `SQLiteStatement.declaredColumns`, where both drivers were measured.
+              // `declaredColumnTypes` omits the key entirely when nothing was declared,
+              // which is the common case here rather than a failure: SQLite declares
+              // nothing for a computed column, a literal, an aggregate or any PRAGMA.
+              declared: declaredColumnTypes(stmt.declaredColumns()),
             };
           } else {
             const stmt = this.db!.prepare(sql);
@@ -1262,6 +1270,10 @@ export class SQLiteProvider extends SQLBaseProvider {
               rows: [],
               fields: [],
               changes: info.changes,
+              // A write has no result columns at all - measured, both drivers answer an
+              // EMPTY column list after `run()` - so there is nothing to declare, and the
+              // key is left off exactly as `fields: []` leaves the names off.
+              declared: {},
             };
           }
         } catch (error) {
@@ -1274,6 +1286,7 @@ export class SQLiteProvider extends SQLBaseProvider {
         fields: result.fields,
         rowCount: result.rows.length || result.changes,
         executionTime,
+        ...result.declared,
       };
     });
   }
@@ -1356,22 +1369,32 @@ export class SQLiteProvider extends SQLBaseProvider {
     this.enforceQueryOnly();
 
     return this.trackQuery(async () => {
-      const { result, executionTime } = await this.measureExecution(async () => {
+      const {
+        result: { rows, declared },
+        executionTime,
+      } = await this.measureExecution(async () => {
         try {
-          return this.db!.prepare(sql).all() as Record[];
+          const stmt = this.db!.prepare(sql);
+          // The rows first and the declarations second, for the reason `query()` above
+          // states: bun:sqlite answers the second question only once the first has been
+          // asked. The budgets below are checked on the rows either way, so a result
+          // refused for being too large carries its types no further than it carries its
+          // rows.
+          const all = stmt.all() as Record[];
+          return { rows: all, declared: declaredColumnTypes(stmt.declaredColumns()) };
         } catch (error) {
           throw mapDatabaseError(error, "sqlite", sql);
         }
       });
 
-      if (result.length > budget.maxResultRows) {
+      if (rows.length > budget.maxResultRows) {
         throw new QueryError(
-          `Read-only execution exceeded the row budget: ${result.length} rows > ${budget.maxResultRows} allowed`,
+          `Read-only execution exceeded the row budget: ${rows.length} rows > ${budget.maxResultRows} allowed`,
           "sqlite",
           sql,
         );
       }
-      const resultBytes = measureResultBytes(result);
+      const resultBytes = measureResultBytes(rows);
       if (resultBytes > budget.maxResultBytes) {
         throw new QueryError(
           `Read-only execution exceeded the byte budget: ${resultBytes} bytes > ${budget.maxResultBytes} allowed`,
@@ -1393,10 +1416,11 @@ export class SQLiteProvider extends SQLBaseProvider {
       }
 
       return {
-        rows: result,
-        fields: result.length > 0 ? Object.keys(result[0]) : [],
-        rowCount: result.length,
+        rows,
+        fields: rows.length > 0 ? Object.keys(rows[0]) : [],
+        rowCount: rows.length,
         executionTime,
+        ...declared,
       };
     });
   }
diff --git a/src/lib/sql/update-target.ts b/src/lib/sql/update-target.ts
index 922aa518d..b6156cfac 100644
--- a/src/lib/sql/update-target.ts
+++ b/src/lib/sql/update-target.ts
@@ -368,6 +368,121 @@ function opensASubquery(pieces: Piece[], fromOffset: number, type?: DatabaseType
  * Only the shape is read. The statement is not executed and no part of it other than the
  * table reference is copied anywhere.
  */
+/**
+ * Whether `column` reaches the grid straight off the base table, rather than through an
+ * expression or a rename.
+ *
+ * The key an inline edit writes against is picked by NAME off the result's field list, and
+ * 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 column called `product_id`;
+ * `products` really has a `product_id`; and the `UPDATE ... WHERE product_id = 1` that
+ * follows lands on whichever product that is, not on the row anybody was looking at.
+ * Measured against PostgreSQL 16: two cells edited, two rows written, neither of them the
+ * ones on screen, and the apply reported both as accepted. `SELECT sku AS product_id`
+ * is the same defect spelled shorter.
+ *
+ * `*` is the safe case and the common one: every field is the table's own. Otherwise the
+ * item that produces this name has to BE the column - one identifier, or a qualified one,
+ * and any `AS` on it has to name the column it already names.
+ *
+ * Refusing when the shape cannot be read, like everything else here: a key this reader
+ * cannot vouch for is one it should not let a write aim with.
+ */
+export function selectsPlainColumn(sql: string, column: string, type?: DatabaseType): boolean {
+  const pieces = readPieces(sql, type);
+  if (typeof pieces === "string") return false;
+  const top = pieces.filter((piece) => piece.depth === 0);
+
+  const select = top.findIndex((piece) => isWord(piece, "SELECT"));
+  const from = top.findIndex((piece) => isWord(piece, "FROM"));
+  if (select === -1 || from === -1 || from < select) return false;
+
+  // `DISTINCT`, `ALL`, and DuckDB's and PostgreSQL's `DISTINCT ON (...)` sit between the
+  // keyword and the list. They say how many rows come back, not where a field comes from.
+  let start = select + 1;
+  if (isWord(top[start], "ALL") || isWord(top[start], "DISTINCT")) {
+    const distinct = isWord(top[start], "DISTINCT");
+    start++;
+    if (distinct && isWord(top[start], "ON")) {
+      start++;
+      // The parenthesised list is one `(` and one `)` at this level; its contents are deeper.
+      if (top[start]?.kind === "other" && top[start].text === "(") {
+        start++;
+        while (start < from && !(top[start].kind === "other" && top[start].text === ")")) start++;
+        start++;
+      }
+    }
+  }
+
+  // The select list, split on its own commas. A comma inside parens belongs to a function's
+  // arguments, and those pieces are not at this depth to begin with.
+  const items: Piece[][] = [[]];
+  for (const piece of top.slice(start, from)) {
+    if (piece.kind === "other" && piece.text === ",") items.push([]);
+    else items[items.length - 1].push(piece);
+  }
+
+  const named = (piece: Piece) => (piece.kind === "quoted" ? sql.slice(piece.start + 1, piece.end - 1) : piece.text);
+  const sameName = (a: string, b: string) => a === b || a.toLowerCase() === b.toLowerCase();
+  const isName = (piece: Piece | undefined) =>
+    piece !== undefined && (piece.kind === "name" || piece.kind === "quoted");
+
+  /**
+   * `*` on its own, or `t.*`. Not any `*` anywhere in the item: `ROW_NUMBER() OVER (...) * 1`
+   * multiplies, and reading that as a star let a computed field pass as the table's own -
+   * measured, and it is the whole defect this function exists to stop.
+   */
+  const isStar = (item: Piece[]) => {
+    const star = (piece: Piece | undefined) => piece?.kind === "other" && piece.text === "*";
+    if (item.length === 1) return star(item[0]);
+    return item.length === 3 && isName(item[0]) && item[1].kind === "other" && item[1].text === "." && star(item[2]);
+  };
+
+  // The LAST item that produces this name is the one the grid reads. Drivers build a row
+  // object keyed by field name, so `SELECT product_id, sku AS product_id` hands over sku's
+  // value under that name - measured on node-postgres - and deciding on the first match
+  // would vouch for a column the user never sees.
+  let answer = false;
+  for (const item of items) {
+    if (item.length === 0) continue;
+
+    if (isStar(item)) {
+      // Every one of the table's columns, this one included, unless a later item renames
+      // over it.
+      answer = true;
+      continue;
+    }
+
+    // Strip a trailing alias, with or without the keyword.
+    let body = item;
+    let alias: string | null = null;
+    const last = item[item.length - 1];
+    if (item.length >= 2 && isName(last)) {
+      if (isWord(item[item.length - 2], "AS")) {
+        alias = named(last);
+        body = item.slice(0, item.length - 2);
+      } else if (isName(item[item.length - 2])) {
+        // Two identifiers side by side is an alias with the keyword left out.
+        alias = named(last);
+        body = item.slice(0, item.length - 1);
+      }
+    }
+
+    // A plain reference is a chain of identifiers joined by dots: `c`, `t.c`, `s.t.c`.
+    const isReference =
+      body.length > 0 &&
+      body.length % 2 === 1 &&
+      body.every((piece, index) => (index % 2 === 0 ? isName(piece) : piece.kind === "other" && piece.text === "."));
+
+    const source = isReference ? named(body[body.length - 1]) : null;
+    const output = alias ?? source;
+    if (output === null || !sameName(output, column)) continue;
+    // An alias that renames is a different column wearing this name.
+    answer = source !== null && sameName(source, column);
+  }
+  return answer;
+}
+
 export function resolveUpdateTarget(sql: string, type?: DatabaseType): UpdateTarget {
   const pieces = readPieces(sql, type);
   if (pieces === "unterminated") {
diff --git a/tests/components/SchemaDiff.test.tsx b/tests/components/SchemaDiff.test.tsx
index a8a845c27..7977457d5 100644
--- a/tests/components/SchemaDiff.test.tsx
+++ b/tests/components/SchemaDiff.test.tsx
@@ -4,6 +4,7 @@ import "../helpers/mock-navigation";
 
 import { mock } from "bun:test";
 import React from "react";
+import * as ReactNS from "react";
 
 // ── Mock data ────────────────────────────────────────────────────────────────
 
@@ -155,8 +156,17 @@ const mockSnapshots = [
   },
 ];
 
-const mockGetSchemaSnapshots = mock(() => [...mockSnapshots]);
-const mockSaveSchemaSnapshot = mock(() => {});
+/**
+ * The store, as a store. The panel reads back what it just wrote, because the real one
+ * SWALLOWS a quota refusal - `saveSchemaSnapshot` returns nothing and `local-storage.ts`
+ * catches the error - so a mock that accepts a write and then answers without it would be
+ * testing the panel against a store that does not exist.
+ */
+const savedSnapshots: unknown[] = [];
+const mockGetSchemaSnapshots = mock(() => [...mockSnapshots, ...savedSnapshots]);
+const mockSaveSchemaSnapshot = mock((snapshot?: unknown) => {
+  if (snapshot !== undefined) savedSnapshots.push(snapshot);
+});
 const mockDeleteSchemaSnapshot = mock(() => {});
 const mockGetConnections = mock(() => [
   {
@@ -189,6 +199,36 @@ mock.module("@/lib/storage", () => ({
   },
 }));
 
+// ── Watch the panel's own state setters ──────────────────────────────────────
+
+/**
+ * A `setState` on an unmounted component is a SILENT no-op in React 19. It does not warn,
+ * it does not throw, and nothing outside the component can tell that it happened - measured
+ * here, on this React, before these tests were written. So "the panel writes nothing after
+ * it is gone" cannot be held by watching the screen, the store or the console: there is
+ * nothing to watch. It is held by watching the setters.
+ *
+ * `useState` is wrapped once, for the whole file, and records only while `stateWrites` is an
+ * array - which `recordStateWrites()` switches on for the span of one assertion, so the rest
+ * of the suite pays nothing and sees nothing. The real hook does the work; this only counts.
+ */
+let stateWrites: string[] | null = null;
+const realUseState = ReactNS.useState;
+const watchedReact = {
+  ...ReactNS,
+  useState: (initial: unknown) => {
+    const [value, set] = (realUseState as (i: unknown) => [unknown, (v: unknown) => void])(initial);
+    return [
+      value,
+      (next: unknown) => {
+        if (stateWrites) stateWrites.push(typeof next === "function" ? "fn" : String(JSON.stringify(next)));
+        return set(next);
+      },
+    ];
+  },
+};
+mock.module("react", () => ({ ...watchedReact, default: watchedReact }));
+
 mock.module("@/hooks/use-all-connections", () => ({
   useAllConnections: () => ({
     connections: mockGetConnections(),
@@ -228,6 +268,23 @@ function getTargetCallback() {
   return selectCallbacks.get("__empty__") || selectCallbacks.get("");
 }
 
+/**
+ * Record every state write the panel performs, until `stop()` is called.
+ *
+ * Switched on AFTER the panel has been unmounted, so what it returns is exactly the set of
+ * writes a dead component performed - which must be empty.
+ */
+function recordStateWrites() {
+  stateWrites = [];
+  return {
+    stop() {
+      const seen = stateWrites ?? [];
+      stateWrites = null;
+      return seen;
+    },
+  };
+}
+
 /** Helper to set native input value and trigger React change handler */
 function changeInput(input: HTMLInputElement, value: string) {
   // React controlled inputs need nativeInputValueSetter
@@ -248,11 +305,21 @@ describe("SchemaDiff", () => {
     mockGenerateMigrationSQL.mockClear();
     mockGetSchemaSnapshots.mockClear();
     mockSaveSchemaSnapshot.mockClear();
+    savedSnapshots.length = 0;
     mockDeleteSchemaSnapshot.mockClear();
     mockGetConnections.mockClear();
     selectCallbacks.clear();
     capturedTimelineProps = {};
 
+    // The default behaviour of the two write mocks, restored here rather than only at their
+    // declaration: `mockClear` forgets the CALLS and keeps the IMPLEMENTATION, so a test that
+    // swaps one for a store with the real filter-by-id or the real 50-row cap would otherwise
+    // hand that store to every test after it.
+    mockSaveSchemaSnapshot.mockImplementation((snapshot?: unknown) => {
+      if (snapshot !== undefined) savedSnapshots.push(snapshot);
+    });
+    mockDeleteSchemaSnapshot.mockImplementation(() => {});
+
     mockDiffSchemas.mockImplementation(() => structuredClone(mockDiffWithChanges));
     mockGenerateMigrationSQL.mockImplementation(
       () => "CREATE TABLE new_table (\n  id integer\n);\nDROP TABLE old_table;",
@@ -347,6 +414,95 @@ describe("SchemaDiff", () => {
   // ═══════════════════════════════════════════════════════════════════════════
 
   describe("snapshot controls", () => {
+    /**
+     * The same answer as `answerSchemaReads`, except the INVENTORY half is held open until
+     * the returned `release` is called.
+     *
+     * `provider-meta` still answers at once, so `readLiveSchema` gets all the way to the
+     * read that matters and stops THERE. The gate lives in this closure and not in
+     * `globalThis.fetch`, which is the point: the caller can swap `globalThis.fetch` for a
+     * second connection while this first read is suspended, and release it afterwards.
+     */
+    function holdSchemaRead(objects: Array<{ name: string }> = [{ name: "users" }], ok = true) {
+      let release!: () => void;
+      const held = new Promise((resolve) => {
+        release = resolve;
+      });
+      const orig = globalThis.fetch;
+      globalThis.fetch = mock((url: string) =>
+        String(url).includes("provider-meta")
+          ? Promise.resolve({
+              ok: true,
+              json: () =>
+                Promise.resolve({
+                  capabilities: {
+                    queryLanguage: "sql",
+                    objectKinds: [{ id: "table", role: "relation", label: "Table", labelPlural: "Tables" }],
+                  },
+                }),
+            })
+          : held.then(() => ({
+              ok,
+              json: () =>
+                Promise.resolve(
+                  ok
+                    ? {
+                        objects: objects.map((o) => ({ name: o.name, kind: "table", path: ["public", o.name] })),
+                        details: objects.map((o) => ({
+                          path: ["public", o.name],
+                          columns: [],
+                          indexes: [],
+                          foreignKeys: [],
+                        })),
+                      }
+                    : { error: "the connection you left is gone" },
+                ),
+            })),
+      ) as unknown as typeof fetch;
+      return { release, restore: () => void (globalThis.fetch = orig) };
+    }
+
+    /**
+     * A snapshot now reads the database itself, so these tests have to answer that read.
+     * They did not before, when it froze whatever the panel happened to be holding — which
+     * is the defect: the sequence the Diff tab exists for (snapshot, change the database,
+     * compare) answered "No differences found" with the panel left open, because the
+     * snapshot and the other side were both the mount-time copy.
+     */
+    function answerSchemaReads(objects: Array<{ name: string }> = [{ name: "users" }]) {
+      const orig = globalThis.fetch;
+      const fetchMock = mock((url: string) =>
+        Promise.resolve(
+          url.includes("provider-meta")
+            ? {
+                ok: true,
+                json: () =>
+                  Promise.resolve({
+                    capabilities: {
+                      queryLanguage: "sql",
+                      objectKinds: [{ id: "table", role: "relation", label: "Table", labelPlural: "Tables" }],
+                    },
+                  }),
+              }
+            : {
+                ok: true,
+                json: () =>
+                  Promise.resolve({
+                    objects: objects.map((o) => ({ name: o.name, kind: "table", path: ["public", o.name] })),
+                    details: objects.map((o) => ({
+                      path: ["public", o.name],
+                      columns: [],
+                      indexes: [],
+                      foreignKeys: [],
+                    })),
+                  }),
+              },
+        ),
+      );
+      globalThis.fetch = fetchMock as unknown as typeof fetch;
+      return { fetchMock, restore: () => void (globalThis.fetch = orig) };
+    }
+
     test("renders Snapshot button", () => {
       const { getByText } = renderDiff();
       expect(getByText("Snapshot")).toBeTruthy();
@@ -376,13 +532,17 @@ describe("SchemaDiff", () => {
       expect(queryByPlaceholderText("Label (optional)...")).toBeNull();
     });
 
-    test("Save button calls storage.saveSchemaSnapshot", () => {
+    test("Save button calls storage.saveSchemaSnapshot", async () => {
+      const { restore } = answerSchemaReads();
       const { getByText, getByPlaceholderText, queryByPlaceholderText } = renderDiff();
       fireEvent.click(getByText("Snapshot"));
 
       const input = getByPlaceholderText("Label (optional)...") as HTMLInputElement;
       changeInput(input, "My label");
-      fireEvent.click(getByText("Save"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      restore();
 
       expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
       const saved = (mockSaveSchemaSnapshot.mock.calls as unknown[][])[0][0] as Record;
@@ -394,10 +554,14 @@ describe("SchemaDiff", () => {
       expect(queryByPlaceholderText("Label (optional)...")).toBeNull();
     });
 
-    test("Save with empty label sets label to undefined", () => {
+    test("Save with empty label sets label to undefined", async () => {
+      const { restore } = answerSchemaReads();
       const { getByText } = renderDiff();
       fireEvent.click(getByText("Snapshot"));
-      fireEvent.click(getByText("Save"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      restore();
 
       expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
       expect(
@@ -405,63 +569,1429 @@ describe("SchemaDiff", () => {
       ).toBeUndefined();
     });
 
-    test("Enter key in label input triggers snapshot save", () => {
-      const { getByText, getByPlaceholderText } = renderDiff();
-      fireEvent.click(getByText("Snapshot"));
+    test("Enter key in label input triggers snapshot save", async () => {
+      const { restore } = answerSchemaReads();
+      const { getByText, getByPlaceholderText } = renderDiff();
+      fireEvent.click(getByText("Snapshot"));
+
+      const input = getByPlaceholderText("Label (optional)...") as HTMLInputElement;
+      changeInput(input, "Enter label");
+      await act(async () => {
+        fireEvent.keyDown(input, { key: "Enter" });
+      });
+      restore();
+
+      expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
+    });
+
+    test("records what the database holds NOW, not what the panel read when it opened", async () => {
+      // The whole point of the tab: snapshot, change the database, compare. With the panel
+      // left open that answered "No differences found", because the snapshot froze the
+      // mount-time copy and so did the other side. The panel is mounted here, the database
+      // gains a table, and the snapshot has to carry it.
+      const first = answerSchemaReads([{ name: "users" }]);
+      const { getByText } = renderDiff();
+      await act(async () => {});
+      first.restore();
+
+      const second = answerSchemaReads([{ name: "users" }, { name: "added_after_open" }]);
+      fireEvent.click(getByText("Snapshot"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      second.restore();
+
+      expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
+      const saved = (mockSaveSchemaSnapshot.mock.calls as unknown[][])[0][0] as { schema: Array<{ name: string }> };
+      expect(saved.schema.map((o) => o.name).sort()).toEqual(["added_after_open", "users"]);
+    });
+
+    test("saves nothing when that read fails, and says why", async () => {
+      const first = answerSchemaReads([{ name: "users" }]);
+      const { getByText, findByText } = renderDiff();
+      await act(async () => {});
+      first.restore();
+
+      const orig = globalThis.fetch;
+      globalThis.fetch = mock(() =>
+        Promise.resolve({ ok: false, json: () => Promise.resolve({ error: "the database refused the read" }) }),
+      ) as unknown as typeof fetch;
+      fireEvent.click(getByText("Snapshot"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      globalThis.fetch = orig;
+
+      // A stale snapshot is the defect again with a longer fuse, so nothing is written.
+      expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+      expect(await findByText(/No snapshot was saved: the database refused the read/)).toBeTruthy();
+    });
+
+    test("Enter twice saves once, not twice", async () => {
+      const { restore } = answerSchemaReads();
+      const { getByText, getByPlaceholderText } = renderDiff();
+      await act(async () => {});
+      fireEvent.click(getByText("Snapshot"));
+      const input = getByPlaceholderText("Label (optional)...") as HTMLInputElement;
+
+      await act(async () => {
+        fireEvent.keyDown(input, { key: "Enter" });
+        fireEvent.keyDown(input, { key: "Enter" });
+      });
+      restore();
+
+      expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
+    });
+
+    /**
+     * The double-Enter guard, measured. `snapshotInFlight` is a ref and not state because
+     * both presses land in the SAME tick, before React has re-rendered, so both see the
+     * `snapshotting` their callback closed over - `false` - and both begin a read.
+     *
+     * "One snapshot was saved" is not the measurement, and the test above is green with the
+     * guard deleted: the second read supersedes the first, the first asks `isCurrent()` and
+     * is told no, so exactly one snapshot is written either way. What the missing guard
+     * really costs is the two things below, and they are the two things the user pays for -
+     * the database read twice for one press of Save, and a banner blaming them for a race
+     * they did not cause.
+     */
+    test("Enter twice reads the database ONCE, not twice", async () => {
+      const mount = answerSchemaReads();
+      const { getByText, getByPlaceholderText } = renderDiff();
+      await act(async () => {});
+      mount.restore();
+
+      // A fresh answer, so the count below is the snapshot's reads and not the mount's.
+      const { fetchMock, restore } = answerSchemaReads();
+      fireEvent.click(getByText("Snapshot"));
+      const input = getByPlaceholderText("Label (optional)...") as HTMLInputElement;
+      await act(async () => {
+        fireEvent.keyDown(input, { key: "Enter" });
+        fireEvent.keyDown(input, { key: "Enter" });
+      });
+      restore();
+
+      // `inventory` is the read that matters - the whole object surface of the database.
+      // Without the guard this is 2: one press of Save, two round trips to the server.
+      const inventoryReads = (fetchMock.mock.calls as unknown[][]).filter((c) =>
+        String(c[0]).includes("inventory"),
+      ).length;
+      expect(inventoryReads).toBe(1);
+      expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
+    });
+
+    test("Enter twice leaves no banner the user did nothing to earn", async () => {
+      // The half a person actually sees. Without the guard the first read is superseded by
+      // the second, takes that for someone else asking for a newer read, and raises
+      // "the schema was read again before this finished. Press Save again" - over a snapshot
+      // that WAS saved. The user pressed Save, it worked, and the panel tells them it did not.
+      const mount = answerSchemaReads();
+      const { getByText, getByPlaceholderText, queryByText } = renderDiff();
+      await act(async () => {});
+      mount.restore();
+
+      const { restore } = answerSchemaReads();
+      fireEvent.click(getByText("Snapshot"));
+      const input = getByPlaceholderText("Label (optional)...") as HTMLInputElement;
+      await act(async () => {
+        fireEvent.keyDown(input, { key: "Enter" });
+        fireEvent.keyDown(input, { key: "Enter" });
+      });
+      restore();
+
+      expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
+      expect(queryByText(/No snapshot was saved/)).toBeNull();
+      expect(queryByText(/Press Save again/)).toBeNull();
+    });
+
+    test("a storage refusal unlocks the button and says so", async () => {
+      // Snapshots live in localStorage and a snapshot is a whole schema, so a quota refusal
+      // is ordinary. Before the `finally`, this left the button reading "Reading..." for the
+      // life of the panel with nothing on screen explaining it.
+      const { restore } = answerSchemaReads();
+      mockSaveSchemaSnapshot.mockImplementationOnce(() => {
+        throw new Error("the browser refused to store it");
+      });
+      const { getByText, findByText, queryByText } = renderDiff();
+      await act(async () => {});
+      fireEvent.click(getByText("Snapshot"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      restore();
+
+      expect(await findByText(/No snapshot was saved: the browser refused to store it/)).toBeTruthy();
+      expect(queryByText("Reading...")).toBeNull();
+      expect(getByText("Save")).toBeTruthy();
+    });
+
+    test("says a snapshot failed even while the panel's own read is failing too", async () => {
+      // Two different facts: what Current Schema means, and whether the thing you just
+      // pressed wrote anything. Only the first used to reach the screen.
+      const orig = globalThis.fetch;
+      globalThis.fetch = mock(() =>
+        Promise.resolve({ ok: false, json: () => Promise.resolve({ error: "the database is unreachable" }) }),
+      ) as unknown as typeof fetch;
+
+      const { getByText, findByText } = renderDiff();
+      await act(async () => {});
+      fireEvent.click(getByText("Snapshot"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      globalThis.fetch = orig;
+
+      expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+      expect(await findByText(/Current Schema is the explorer's last copy/)).toBeTruthy();
+      expect(await findByText(/No snapshot was saved: the database is unreachable/)).toBeTruthy();
+    });
+
+    test("the same read becomes Current Schema, not just the snapshot", async () => {
+      // The other half of this change: the read a snapshot makes is also stored as the
+      // panel's own copy, so the snapshot and the side it will be compared against are the
+      // same instant. Asserted through the error banner, which is what `liveRead` drives:
+      // after a successful snapshot read the panel is no longer falling back to the
+      // explorer's copy, even though the read it did on mount had failed.
+      const orig = globalThis.fetch;
+      globalThis.fetch = mock(() =>
+        Promise.resolve({ ok: false, json: () => Promise.resolve({ error: "mount read failed" }) }),
+      ) as unknown as typeof fetch;
+      const { getByText, findByText, queryByText } = renderDiff();
+      expect(await findByText(/Current Schema is the explorer's last copy/)).toBeTruthy();
+
+      const { restore } = answerSchemaReads([{ name: "users" }, { name: "added_after_open" }]);
+      fireEvent.click(getByText("Snapshot"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      restore();
+      globalThis.fetch = orig;
+
+      expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
+      // The banner is gone, which it can only be because the snapshot's read replaced the
+      // failed one as Current Schema.
+      expect(queryByText(/Current Schema is the explorer's last copy/)).toBeNull();
+    });
+
+    test("the Save button is disabled while its read is in flight", async () => {
+      const { restore } = answerSchemaReads();
+      let release: (() => void) | undefined;
+      const held = new Promise((resolve) => {
+        release = resolve;
+      });
+      const orig = globalThis.fetch;
+      const passthrough = globalThis.fetch;
+      globalThis.fetch = mock(async (url: string, init?: RequestInit) => {
+        if (String(url).includes("inventory")) await held;
+        return passthrough(url as never, init as never);
+      }) as unknown as typeof fetch;
+
+      const { getByText, container } = renderDiff();
+      fireEvent.click(getByText("Snapshot"));
+      let pending: Promise | undefined;
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+        pending = Promise.resolve();
+        await pending;
+      });
+
+      const saving = Array.from(container.querySelectorAll("button")).find((b) =>
+        b.textContent?.includes("Reading..."),
+      );
+      expect(saving?.disabled).toBe(true);
+      // The label and Cancel go with it: a label typed now would not reach the snapshot the
+      // read is already building, and Cancel would close the panel over a save that is still
+      // going to happen.
+      expect((container.querySelector("input[placeholder='Label (optional)...']") as HTMLInputElement).disabled).toBe(
+        true,
+      );
+      expect(
+        Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Cancel"))?.disabled,
+      ).toBe(true);
+
+      release?.();
+      await act(async () => {
+        await new Promise((r) => setTimeout(r, 0));
+      });
+      globalThis.fetch = orig;
+      restore();
+    });
+    test("leaving the Diff tab while a snapshot read is in flight saves nothing", async () => {
+      // The lock on the label input and Cancel stops the panel being closed out from under a
+      // save that is already running - and it only covers the panel's own buttons. Leaving the
+      // tab walks straight past it: `BottomPanel` mounts one view at a time, so changing tabs
+      // UNMOUNTS this, the read lands afterwards and the snapshot is written for a panel that
+      // is gone, with no banner, no refreshed list, and nothing on screen saying it happened.
+      // Measured before the fix: one snapshot saved after the panel had left the screen.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "a_table" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        // Save is pressed, and THAT read is held open.
+        const held = holdSchemaRead([{ name: "a_table" }]);
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+
+        // The user changes tabs while it is still out. One view at a time, so this is an
+        // unmount and not a hidden panel.
+        mockSaveSchemaSnapshot.mockClear();
+        await act(async () => {
+          view.unmount();
+        });
+
+        await act(async () => {
+          held.release();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        held.restore();
+
+        // Nothing is written for a panel that is no longer on screen.
+        expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    test("leaving the Diff tab while a REMOTE fetch is in flight saves nothing either", async () => {
+      // The other read that writes a snapshot. Choosing a connection to compare against
+      // auto-saves what it reads as a "Live:" snapshot, so the same tab change leaves the
+      // same litter behind - a snapshot of a database nobody is looking at, written by a
+      // panel that no longer exists. It runs on its own counter, so it needs its own answer.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "a_table" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        const held = holdSchemaRead([{ name: "remote_table" }]);
+        const target = getTargetCallback();
+        await act(async () => {
+          target?.("conn:remote-1");
+        });
+
+        mockSaveSchemaSnapshot.mockClear();
+        await act(async () => {
+          view.unmount();
+        });
+
+        await act(async () => {
+          held.release();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        held.restore();
+
+        expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    test("a snapshot read that lands after the connection changed saves nothing and is not kept", async () => {
+      // A read is not instant - it opens a connection and asks a catalog - and the panel
+      // stays usable while it runs, so the user can switch connections inside that window.
+      // The mount effect has a `cancelled` flag for exactly this; the snapshot's own read
+      // needs the same check, or its late answer is written as the CURRENT connection's
+      // Current Schema, the identity test then rejects it, and the panel falls silently back
+      // to the explorer's copy with no banner - #884 again, on a connection the user is
+      // looking at right now.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "a_table" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        // Save is pressed on A, and THAT read is held open.
+        const heldA = holdSchemaRead([{ name: "a_table" }]);
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+
+        // While it is still in flight the user switches to B, whose own read lands.
+        const b = answerSchemaReads([{ name: "b_table" }]);
+        await act(async () => {
+          view.rerender();
+        });
+        b.restore();
+
+        mockSaveSchemaSnapshot.mockClear();
+        await act(async () => {
+          heldA.release();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        heldA.restore();
+
+        // Nothing is written for a connection the user has left.
+        expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+
+        // And B's own read is still what Current Schema means.
+        mockDiffSchemas.mockClear();
+        changeSource("snap-1");
+        changeTarget("current");
+        const current = (mockDiffSchemas.mock.calls as unknown[][]).at(-1)?.[1] as Array<{ name: string }>;
+        expect(current.map((o) => o.name)).toEqual(["b_table"]);
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    test("a snapshot read that FAILS after the connection changed leaves the new one alone", async () => {
+      // The same window, the other outcome. Writing the old connection's failure onto the
+      // new one would put a banner about a database the user is no longer looking at over a
+      // panel that is working.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "a_table" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        const heldA = holdSchemaRead([], false);
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+
+        const b = answerSchemaReads([{ name: "b_table" }]);
+        await act(async () => {
+          view.rerender();
+        });
+        b.restore();
+
+        await act(async () => {
+          heldA.release();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        heldA.restore();
+
+        expect(view.queryByText(/the connection you left is gone/)).toBeNull();
+        mockDiffSchemas.mockClear();
+        changeSource("snap-1");
+        changeTarget("current");
+        const current = (mockDiffSchemas.mock.calls as unknown[][]).at(-1)?.[1] as Array<{ name: string }>;
+        expect(current.map((o) => o.name)).toEqual(["b_table"]);
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    test("snapshot, change the database, compare - the sequence the tab exists for", async () => {
+      // The whole claim, end to end, with the panel never closed. The earlier fix made the
+      // SNAPSHOT read fresh and stopped there, so the other side of the comparison was still
+      // frozen at the moment the snapshot was taken and the answer was "No differences
+      // found" all the same. Choosing a target is what reads again.
+      const a = answerSchemaReads([{ name: "users" }]);
+      const { getByText } = renderDiff();
+      await act(async () => {});
+      a.restore();
+
+      // A snapshot of the schema as it stands: read fresh, so it carries what is there now.
+      const b = answerSchemaReads([{ name: "users" }]);
+      fireEvent.click(getByText("Snapshot"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      b.restore();
+      const saved = (mockSaveSchemaSnapshot.mock.calls as unknown[][])[0][0] as {
+        schema: Array<{ name: string }>;
+      };
+      expect(saved.schema.map((o) => o.name)).toEqual(["users"]);
+
+      // The database gains a table, and a target is chosen WITHOUT leaving the tab.
+      const c = answerSchemaReads([{ name: "users" }, { name: "added_between" }]);
+      mockDiffSchemas.mockClear();
+      changeTarget("snap-1");
+      await act(async () => {
+        await new Promise((r) => setTimeout(r, 0));
+      });
+      c.restore();
+
+      // Current Schema was read again, so the comparison sees the new table. Without the
+      // second read this side would still be the schema of the moment the snapshot was taken.
+      const [source] = (mockDiffSchemas.mock.calls as unknown[][]).at(-1) as [Array<{ name: string }>];
+      expect(source.map((o) => o.name).sort()).toEqual(["added_between", "users"]);
+    });
+
+    test("a snapshot overtaken on the SAME connection says so instead of vanishing", async () => {
+      // Choosing a target reads the connection too, so a snapshot in flight can be overtaken
+      // without the connection changing at all. Returning quietly there saved nothing and
+      // said nothing: the button went back to "Save" and the user believed it had saved.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "users" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        const held = holdSchemaRead([{ name: "users" }]);
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+
+        // A target is chosen while that read is still out, which begins a newer one.
+        const b = answerSchemaReads([{ name: "users" }]);
+        await act(async () => {
+          changeTarget("snap-1");
+        });
+        b.restore();
+
+        mockSaveSchemaSnapshot.mockClear();
+        await act(async () => {
+          held.release();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        held.restore();
+
+        expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+        expect(await view.findByText(/read again before this finished/)).toBeTruthy();
+        expect(view.queryByText("Reading...")).toBeNull();
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    /**
+     * Two reads of the SAME connection, settled in an order the test chooses.
+     *
+     * `holdSchemaRead` gates one read by swapping `globalThis.fetch` wholesale, so it cannot
+     * express the ordinary order: the snapshot's own read settling FIRST and the read that
+     * overtook it settling after. That order is the common one - the overtaken read was
+     * started earlier, so it is answering the older question and usually answers first - and
+     * it is the order the defect lives in, so it has to be expressible. `provider-meta` still
+     * answers at once; every inventory read parks here until the test settles it by index.
+     */
+    function queuedSchemaReads() {
+      const orig = globalThis.fetch;
+      type ReadOutcome = { ok: true; objects: Array<{ name: string }> } | { ok: false; error: string };
+      type Answer = { ok: boolean; json: () => Promise };
+      const pending: Array<(outcome: ReadOutcome) => void> = [];
+      globalThis.fetch = mock((url: string) =>
+        String(url).includes("provider-meta")
+          ? Promise.resolve({
+              ok: true,
+              json: () =>
+                Promise.resolve({
+                  capabilities: {
+                    queryLanguage: "sql",
+                    objectKinds: [{ id: "table", role: "relation", label: "Table", labelPlural: "Tables" }],
+                  },
+                }),
+            })
+          : new Promise((resolve) => {
+              pending.push((outcome) =>
+                resolve({
+                  ok: outcome.ok,
+                  json: () =>
+                    Promise.resolve(
+                      outcome.ok
+                        ? {
+                            objects: outcome.objects.map((o) => ({
+                              name: o.name,
+                              kind: "table",
+                              path: ["public", o.name],
+                            })),
+                            details: outcome.objects.map((o) => ({
+                              path: ["public", o.name],
+                              columns: [],
+                              indexes: [],
+                              foreignKeys: [],
+                            })),
+                          }
+                        : { error: outcome.error },
+                    ),
+                }),
+              );
+            }),
+      ) as unknown as typeof fetch;
+      /** Let every read that has been ISSUED get as far as this queue. */
+      const flush = () =>
+        act(async () => {
+          await new Promise((r) => setTimeout(r, 0));
+        });
+      const settle = async (index: number, outcome: ReadOutcome) => {
+        pending[index](outcome);
+        await flush();
+      };
+      return { pending, flush, settle, restore: () => void (globalThis.fetch = orig) };
+    }
+
+    test("an overtaken snapshot still says so when the newer read settles AFTER it", async () => {
+      // The ordinary order, and the one the earlier attempt never ran. The overtaken read
+      // was started first, so it is the first to answer: it raises the banner, and the read
+      // that overtook it lands a tick later. Clearing the report on any successful read of
+      // this connection wiped the banner in that tick, and what the user was left with was a
+      // button back at "Save", nothing saved, and nothing on screen - the exact silence the
+      // banner exists to end.
+      const q = queuedSchemaReads();
+      try {
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        await q.flush();
+        await q.settle(0, { ok: true, objects: [{ name: "users" }] });
+
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+        await q.flush();
+
+        // Choosing a target begins a newer read of the same connection.
+        await act(async () => {
+          changeTarget("snap-1");
+        });
+        await q.flush();
+
+        mockSaveSchemaSnapshot.mockClear();
+        // The overtaken snapshot answers FIRST.
+        await q.settle(1, { ok: true, objects: [{ name: "users" }] });
+        expect(view.queryByText(/read again before this finished/)).not.toBeNull();
+
+        // The read that overtook it answers second, and it is a read of this connection
+        // that worked - which is precisely what used to wipe the banner.
+        await q.settle(2, { ok: true, objects: [{ name: "users" }] });
+
+        expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+        expect(view.queryByText(/read again before this finished/)).not.toBeNull();
+        expect(view.queryByText("Reading...")).toBeNull();
+      } finally {
+        q.restore();
+      }
+    });
+
+    test("an overtaken snapshot survives the panel's OWN re-read of the same connection", async () => {
+      // The other way a newer read starts, and nothing the user did. The embedded host hands
+      // over a fresh connection OBJECT with the same id - `use-connection-adapter.ts` builds
+      // it with a `useMemo` over a prop - so the mount effect runs again and reads the same
+      // database. The snapshot in flight is overtaken all the same, and the banner has to
+      // outlive that read too, not just a target selection.
+      const q = queuedSchemaReads();
+      try {
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        await q.flush();
+        await q.settle(0, { ok: true, objects: [{ name: "users" }] });
+
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+        await q.flush();
+
+        // Same id, different object.
+        await act(async () => {
+          view.rerender();
+        });
+        await q.flush();
+
+        mockSaveSchemaSnapshot.mockClear();
+        await q.settle(1, { ok: true, objects: [{ name: "users" }] });
+        expect(view.queryByText(/read again before this finished/)).not.toBeNull();
+        await q.settle(2, { ok: true, objects: [{ name: "users" }] });
+
+        expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+        expect(view.queryByText(/read again before this finished/)).not.toBeNull();
+      } finally {
+        q.restore();
+      }
+    });
+
+    test("a snapshot failure on one connection does not sit over another that is working", async () => {
+      // The reason the report carries the connection it is about. A banner over a database
+      // the user is not looking at is its own small lie, and the panel outlives a switch.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "a_table" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        globalThis.fetch = mock(() =>
+          Promise.resolve({ ok: false, json: () => Promise.resolve({ error: "the database blinked" }) }),
+        ) as unknown as typeof fetch;
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+        expect(await view.findByText(/No snapshot was saved: the database blinked/)).toBeTruthy();
+
+        // The user moves to another database, which reads cleanly.
+        const b = answerSchemaReads([{ name: "b_table" }]);
+        await act(async () => {
+          view.rerender();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        b.restore();
+        expect(view.queryByText(/No snapshot was saved/)).toBeNull();
+
+        // Back on the connection it was about, it is still true: nothing was written for it,
+        // and a later read of it that works does not write it.
+        const c = answerSchemaReads([{ name: "a_table" }]);
+        await act(async () => {
+          view.rerender();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        c.restore();
+        expect(view.queryByText(/No snapshot was saved: the database blinked/)).not.toBeNull();
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    test("a panel-read failure and a snapshot failure are two facts, and both stay on screen", async () => {
+      // One says what "Current Schema" currently means; the other says a snapshot the user
+      // asked for was not written. Neither answers the other, so neither may erase it.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "users" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        // Nothing is written for the snapshot.
+        globalThis.fetch = mock(() =>
+          Promise.resolve({ ok: false, json: () => Promise.resolve({ error: "the database blinked" }) }),
+        ) as unknown as typeof fetch;
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+        expect(await view.findByText(/No snapshot was saved: the database blinked/)).toBeTruthy();
+
+        // The panel reads this same connection again and it WORKS. That refreshes Current
+        // Schema. It does not write the snapshot, so it does not answer the report.
+        const b = answerSchemaReads([{ name: "users" }]);
+        await act(async () => {
+          view.rerender();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        b.restore();
+
+        // The read after that fails, so Current Schema falls back to the explorer's copy.
+        globalThis.fetch = mock(() =>
+          Promise.resolve({ ok: false, json: () => Promise.resolve({ error: "the catalog is gone" }) }),
+        ) as unknown as typeof fetch;
+        await act(async () => {
+          view.rerender();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+
+        expect(view.queryByText(/which may be out of date: the catalog is gone/)).not.toBeNull();
+        expect(view.queryByText(/No snapshot was saved: the database blinked/)).not.toBeNull();
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    test("Dismiss is the way out, and it clears only the snapshot report", async () => {
+      // No read clears the report any more, so there has to be something on the screen that
+      // does. Pressing Save again is a retry that can fail again; leaving the connection only
+      // hides it. A labelled button is the only exit a user does not have to guess at.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "users" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        globalThis.fetch = mock(() =>
+          Promise.resolve({ ok: false, json: () => Promise.resolve({ error: "the database blinked" }) }),
+        ) as unknown as typeof fetch;
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+        expect(await view.findByText(/No snapshot was saved: the database blinked/)).toBeTruthy();
+
+        // The panel's own read of this connection is failing at the same time.
+        await act(async () => {
+          view.rerender();
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        expect(view.queryByText(/which may be out of date: the database blinked/)).not.toBeNull();
+
+        await act(async () => {
+          fireEvent.click(view.getByText("Dismiss"));
+        });
+        expect(view.queryByText(/No snapshot was saved/)).toBeNull();
+        // The panel's own warning is not the snapshot report and is left where it was.
+        expect(view.queryByText(/which may be out of date: the database blinked/)).not.toBeNull();
+
+        // Dismissing one report does not silence the next. The label panel is still open -
+        // a save that failed leaves what was typed where it was - so Save is still there to
+        // press, and failing again says so again.
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+        expect(await view.findByText(/No snapshot was saved: the database blinked/)).toBeTruthy();
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    test("a snapshot that works clears the report the last failed one left", async () => {
+      // The report is spent when the thing it reports on is done. A new attempt clears it at
+      // the start, so a snapshot that is written leaves nothing behind.
+      const origFetch = globalThis.fetch;
+      try {
+        const a = answerSchemaReads([{ name: "users" }]);
+        let view!: ReturnType;
+        await act(async () => {
+          view = renderDiff();
+        });
+        a.restore();
+
+        globalThis.fetch = mock(() =>
+          Promise.resolve({ ok: false, json: () => Promise.resolve({ error: "the database blinked" }) }),
+        ) as unknown as typeof fetch;
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+        expect(await view.findByText(/No snapshot was saved: the database blinked/)).toBeTruthy();
+
+        // The label panel is still open after a failure, so the same Save is pressed again.
+        const b = answerSchemaReads([{ name: "users" }]);
+        mockSaveSchemaSnapshot.mockClear();
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+          await new Promise((r) => setTimeout(r, 0));
+        });
+        b.restore();
+
+        expect(mockSaveSchemaSnapshot).toHaveBeenCalled();
+        expect(view.queryByText(/No snapshot was saved/)).toBeNull();
+      } finally {
+        globalThis.fetch = origFetch;
+      }
+    });
+
+    test("comparing two snapshots reads no database at all", async () => {
+      const a = answerSchemaReads([{ name: "users" }]);
+      const { container } = renderDiff();
+      await act(async () => {});
+      a.restore();
+
+      const orig = globalThis.fetch;
+      const counted = mock(() =>
+        Promise.resolve({ ok: true, json: () => Promise.resolve({ objects: [], details: [] }) }),
+      );
+      globalThis.fetch = counted as unknown as typeof fetch;
+      await act(async () => {
+        changeSource("snap-1");
+        changeTarget("snap-1");
+        await new Promise((r) => setTimeout(r, 0));
+      });
+      globalThis.fetch = orig;
+
+      // Two files against each other. A round trip here changes nothing either side shows.
+      expect(counted).not.toHaveBeenCalled();
+      expect(container).toBeTruthy();
+    });
+
+    test("takeSnapshot does nothing when connection is null", () => {
+      // Snapshot button is disabled for null connection, so storage should not be called
+      renderDiff({ connection: null });
+      expect(mockSaveSchemaSnapshot).not.toHaveBeenCalled();
+    });
+
+    test("snapshot save refreshes snapshot list", async () => {
+      const { restore } = answerSchemaReads();
+      const { getByText } = renderDiff();
+      const callsBefore = mockGetSchemaSnapshots.mock.calls.length;
+      fireEvent.click(getByText("Snapshot"));
+      await act(async () => {
+        fireEvent.click(getByText("Save"));
+      });
+      restore();
+      expect(mockGetSchemaSnapshots.mock.calls.length).toBeGreaterThan(callsBefore);
+    });
+  });
+
+  // ═══════════════════════════════════════════════════════════════════════════
+  // Source/Target Selection
+  // ═══════════════════════════════════════════════════════════════════════════
+
+  describe("source/target selection", () => {
+    test("selecting a target triggers diff display", () => {
+      const { queryByText } = renderDiff();
+      changeTarget("snap-1");
+      // diff has changes → summary should appear
+      expect(queryByText(/1 added, 1 removed, 1 modified/)).toBeTruthy();
+    });
+
+    test("selecting same source and target shows same-schema message", () => {
+      const { getByText } = renderDiff();
+      changeTarget("current");
+      // source=current, target=current → same → null diff
+      expect(getByText("Cannot compare same schema with itself")).toBeTruthy();
+    });
+
+    test("changing source updates diff", () => {
+      renderDiff();
+      changeSource("snap-1");
+      changeTarget("current");
+      expect(mockDiffSchemas).toHaveBeenCalled();
+    });
+  });
+
+  // ═══════════════════════════════════════════════════════════════════════════
+  // Reading the database again without leaving the tab (#35)
+  // ═══════════════════════════════════════════════════════════════════════════
+
+  describe("refreshing the current schema", () => {
+    /**
+     * Every inventory read parked until this test settles it, by index.
+     *
+     * `pending.length` is therefore the number of reads the panel has ISSUED, which is the
+     * measurement these tests are about: the defect is a gesture that issues none.
+     *
+     * Local rather than borrowed from the snapshot block, which gates reads the same way:
+     * the helpers there are scoped to that block, and lifting them out would have rewritten
+     * the tests that hold the snapshot rules to prove something about a button.
+     */
+    function schemaReads() {
+      const orig = globalThis.fetch;
+      type Outcome = { ok: true; objects: string[] } | { ok: false; error: string };
+      type Answer = { ok: boolean; json: () => Promise };
+      const pending: Array<(outcome: Outcome) => void> = [];
+      globalThis.fetch = mock((url: string) => {
+        if (String(url).includes("provider-meta")) {
+          return Promise.resolve({
+            ok: true,
+            json: () =>
+              Promise.resolve({
+                capabilities: {
+                  queryLanguage: "sql",
+                  objectKinds: [{ id: "table", role: "relation", label: "Table", labelPlural: "Tables" }],
+                },
+              }),
+          });
+        }
+        return new Promise((resolve) => {
+          pending.push((outcome) =>
+            resolve({
+              ok: outcome.ok,
+              json: () =>
+                Promise.resolve(
+                  outcome.ok
+                    ? {
+                        objects: outcome.objects.map((name) => ({ name, kind: "table", path: ["public", name] })),
+                        details: outcome.objects.map((name) => ({
+                          path: ["public", name],
+                          columns: [],
+                          indexes: [],
+                          foreignKeys: [],
+                        })),
+                      }
+                    : { error: outcome.error },
+                ),
+            }),
+          );
+        });
+      }) as unknown as typeof fetch;
+      /** Let every read that has been ISSUED get as far as this queue. */
+      const flush = () =>
+        act(async () => {
+          await new Promise((r) => setTimeout(r, 0));
+        });
+      const settle = async (index: number, outcome: Outcome) => {
+        pending[index](outcome);
+        await flush();
+      };
+      return { pending, flush, settle, restore: () => void (globalThis.fetch = orig) };
+    }
+
+    /** The control by its label, which is also its accessible name. */
+    function refreshButton(view: ReturnType) {
+      const buttons = Array.from(view.container.querySelectorAll("button"));
+      return buttons.find((b) => /refresh/i.test(b.textContent ?? ""));
+    }
+
+    /** Mount, answer the panel's own read, choose a target, answer that read too. */
+    async function openOnADiff(q: ReturnType) {
+      let view!: ReturnType;
+      await act(async () => {
+        view = renderDiff();
+      });
+      await q.flush();
+      await q.settle(0, { ok: true, objects: ["users"] });
+      await act(async () => {
+        changeTarget("snap-1");
+      });
+      await q.flush();
+      await q.settle(1, { ok: true, objects: ["users"] });
+      return view;
+    }
+
+    /** What "Current Schema" was worth the last time the diff was computed. */
+    function currentSideNames() {
+      const latest = (mockDiffSchemas.mock.calls as unknown[][]).at(-1)!;
+      return (latest[0] as Array<{ name: string }>).map((o) => o.name);
+    }
+
+    test("choosing the target that is ALREADY chosen reads nothing", async () => {
+      // The defect itself. The panel re-reads when the comparison target CHANGES, and a
+      // Select reports a selection only when the value lands on something else - so picking
+      // the same target again is the most the panel can even be told: `setTargetId` with the
+      // id it already holds. React bails out, the effect keyed on that id does not run, and
+      // the database is not read. The gesture a person makes for "look again" does nothing,
+      // which is why it cannot be the answer to #35.
+      const q = schemaReads();
+      try {
+        const view = await openOnADiff(q);
+        expect(q.pending.length).toBe(2);
+
+        const pickTheSameTargetAgain = selectCallbacks.get("snap-1")!;
+        await act(async () => {
+          pickTheSameTargetAgain("snap-1");
+        });
+        await q.flush();
+
+        expect(q.pending.length).toBe(2);
+        expect(view.container.textContent).toContain("Schema Diff");
+      } finally {
+        q.restore();
+      }
+    });
+
+    test("a fresh read can be asked for without leaving the tab", async () => {
+      // The other half of #35, and the half that matters: the panel shipped with exactly one
+      // way to see a change - leave the Diff tab and come back, because `BottomPanel` mounts
+      // one view at a time and returning is a remount. This is that step removed. The user
+      // changes the database, presses the control, and the side that says "current" is the
+      // database as it is now.
+      const q = schemaReads();
+      try {
+        const view = await openOnADiff(q);
+        expect(currentSideNames()).toEqual(["users"]);
+
+        const refresh = refreshButton(view);
+        expect(refresh).toBeTruthy();
+        await act(async () => {
+          fireEvent.click(refresh!);
+        });
+        await q.flush();
+
+        // A read was issued, and no remount happened to issue it.
+        expect(q.pending.length).toBe(3);
+        await q.settle(2, { ok: true, objects: ["added_after_the_snapshot"] });
+        expect(currentSideNames()).toEqual(["added_after_the_snapshot"]);
+      } finally {
+        q.restore();
+      }
+    });
+
+    test("a slow refresh overtaken by a newer read does not win", async () => {
+      // The refresh goes through the panel's read counter rather than around it. Press
+      // Refresh, then Save: the snapshot's read is the newer question, and the refresh is
+      // the slow one that answers LAST with what the database said BEFORE. Writing on the
+      // way out would put a stale "Current Schema" on screen under a snapshot that was just
+      // taken - the stale-copy defect this panel exists to have stopped.
+      const q = schemaReads();
+      try {
+        const view = await openOnADiff(q);
+
+        await act(async () => {
+          fireEvent.click(refreshButton(view)!);
+        });
+        await q.flush();
+        expect(q.pending.length).toBe(3);
+
+        fireEvent.click(view.getByText("Snapshot"));
+        await act(async () => {
+          fireEvent.click(view.getByText("Save"));
+        });
+        await q.flush();
+        expect(q.pending.length).toBe(4);
+
+        // The newer read answers first...
+        await q.settle(3, { ok: true, objects: ["what_the_database_holds_now"] });
+        // ...and the refresh, which was started earlier, answers after it with older objects.
+        await q.settle(2, { ok: true, objects: ["stale_from_the_refresh"] });
+
+        expect(currentSideNames()).toEqual(["what_the_database_holds_now"]);
+        expect(currentSideNames()).not.toContain("stale_from_the_refresh");
+        // The snapshot was the current read, so it is kept - superseding runs one way only.
+        expect(mockSaveSchemaSnapshot).toHaveBeenCalledTimes(1);
+      } finally {
+        q.restore();
+      }
+    });
+
+    test("two clicks in one tick read the database ONCE, not twice", async () => {
+      // The same guard the Save button needs, for the same reason: both clicks land in one
+      // tick, before React has re-rendered, so both see the `refreshing` the handler closed
+      // over - false - and the `disabled` that would have stopped the second is not on the
+      // button yet. The counter keeps the older read from WRITING, so the data stays right;
+      // what breaks is the screen, because the first read to settle runs the `finally` and
+      // hands the button back while the read the user is waiting for is still out.
+      const q = schemaReads();
+      try {
+        const view = await openOnADiff(q);
+        const refresh = refreshButton(view)!;
+
+        await act(async () => {
+          fireEvent.click(refresh);
+          fireEvent.click(refresh);
+        });
+        await q.flush();
+
+        expect(q.pending.length).toBe(3);
+      } finally {
+        q.restore();
+      }
+    });
+
+    test("the button is locked while its own read is in flight, and comes back after", async () => {
+      const q = schemaReads();
+      try {
+        const view = await openOnADiff(q);
+        await act(async () => {
+          fireEvent.click(refreshButton(view)!);
+        });
+        await q.flush();
+
+        expect(refreshButton(view)!.disabled).toBe(true);
+        expect(refreshButton(view)!.textContent).toContain("Refreshing");
+
+        await q.settle(2, { ok: true, objects: ["users"] });
+
+        expect(refreshButton(view)!.disabled).toBe(false);
+        expect(refreshButton(view)!.textContent?.trim()).toBe("Refresh");
+      } finally {
+        q.restore();
+      }
+    });
+
+    test("the button is disabled when there is no connection to read", () => {
+      const view = renderDiff({ connection: null });
+      expect(refreshButton(view)!.disabled).toBe(true);
+    });
+
+    test("it is a keyboard-reachable control with an accessible name", async () => {
+      // A native