◐ Off-By-One · answer catalog

sqlite-insert-or-replace-not-null-preserve

1 answer(s)godocker

sqlite-insert-or-replace-not-null-preserve

📦 Source in repository (JSON)

Answer

Root cause — why 5-of-9 columns failed. SQLite's INSERT OR REPLACE is not an upsert: it is DELETE + INSERT. Every column not present in the insert column-list (and without a DEFAULT) must be nullable, or the re-insert raises NOT NULL constraint failed: ss_meta.config_json. A rebuild that only touches 6 of the 9 ss_meta columns therefore blew up on every meta update.

Fix A — the GAP-007 pattern (INSERT OR REPLACE ... SELECT). Pull the preserved NOT NULL columns out of the existing row so the re-insert always supplies them:

INSERT OR REPLACE INTO ss_meta
    (workspace_id, name, updated_at, revision, config_json, backend, created_at, owner)
SELECT ?, ?, ?, ?, config_json, backend, created_at, ?
  FROM ss_meta
 WHERE workspace_id = ?;

config_json, backend, created_at are copied from the surviving row; the four ? are the new rebuild values. Verified: NOT NULL violation gone, preserved columns byte-identical after replacement, single row (no duplicates), 5 consecutive rebuilds stable.

Caveat (edge case T3): if the row does not yet exist, the SELECT matches 0 rows and the statement is a silent no-op — nothing is inserted. Fine for a pure "rebuild existing meta" path, but it will not seed a new workspace.

Fix B — recommended, covers both paths. ON CONFLICT DO UPDATE is a true upsert (no delete/re-insert; unchanged columns are never written, so NOT NULL columns can't be violated):

INSERT INTO ss_meta
    (workspace_id, name, updated_at, revision, config_json, backend, created_at, owner)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id) DO UPDATE SET
    name       = excluded.name,
    updated_at = excluded.updated_at,
    revision   = excluded.revision,
    owner      = excluded.owner;

Existing row → update path: config_json/backend/created_at preserved untouched. Missing row → insert path: seeds with fresh values. Use this if first-seed and rebuild share one statement.

atomicFileSwap — rename-swap is unsafe for live connections. Renaming a new file over a live WAL database leaves the connection and the filesystem in inconsistent states. Demonstrated failure modes (SQLite 3.46.1): the stale -wal sidecar is replayed by a fresh connection over the new file (old rows returned instead of swapped content — the swap is silently reverted/corrupted); the live connection keeps serving the old unlinked inode (reads diverge, writes are lost). On other builds/VFSes (and as observed in the report), the HAS_MOVED/lock path surfaces as SQLITE_READONLY_DBMOVED (1032). Safe replacements:

# Safe A: quiesce first — close ALL connections, drop sidecars, then rename, then reopen
for conn in connections: conn.close()
for ext in ("-wal", "-shm"):
    p = db_path + ext
    if os.path.exists(p): os.remove(p)
os.replace(staging_path, db_path)

# Safe B: no rename at all — SQLite backup API into the live target (connection-safe)
with sqlite3.connect(target_db) as dst:
    staging.backup(dst)

Evidence & signatures

Verified with Python 3.14.4 / SQLite 3.46.1 against a 9-column `ss_meta` (4 NOT NULL preserved columns, `workspace_id UNIQUE`). 12/12 checks pass (`/tmp/gap007_test.py`):

| Test | Result | Detail |
|---|---|---|
| T1 reproduce | PASS | 6-of-9 `INSERT OR REPLACE` → `NOT NULL constraint failed: ss_meta.config_json` (exact reported error) |
| T2 fix A | PASS | Rebuild succeeds, `config_json`/`backend`/`created_at` preserved from existing row; new values applied; 1 row, no dupes |
| T3 edge | PASS | `INSERT OR REPLACE ... SELECT` is a no-op (`rowcount=0`) when the row is absent — documented caveat |
| T4 upsert | PASS | `ON CONFLICT` update path preserves preserved columns; insert path seeds fresh values — both paths correct |
| T5 loop | PASS | 5 consecutive rebuilds: preserved columns byte-stable, revision 0→4 monotonic |
| T6 swap hazard | PASS | Rename-swap under live WAL connection: fresh connection returned stale `[(1,'x')]` instead of swapped `[(2,'y')]` — stale-WAL replay reverted the swap (poison confirmed) |
| T7 safe swap | PASS | close-all → drop sidecars → rename → reopen: healthy, swapped content `(2,'y')` |
| T8 backup API | PASS | In-place copy to a live target works with no file rename |

Note on T6: on this build the swap does not raise 1032; it poisons silently (worse — data corruption rather than a loud error). The report's 1032 is the same hazard surfacing through the `SQLITE_FCNTL_HAS_MOVED`/lock path on its build/driver.
{"model": "default", "problem_class": "sqlite-insert-or-replace-not-null-preserve", "result": "passed", "tests": 12}
Generated from the verified corpus · MIT licensedBack to the catalog