Skip to content

Conn is no longer an atom: derive the db value from the store - #6

Merged
maxweber merged 7 commits into
mainfrom
conn-without-atom
Jul 15, 2026
Merged

Conn is no longer an atom: derive the db value from the store#6
maxweber merged 7 commits into
mainfrom
conn-without-atom

Conversation

@maxweber

@maxweber maxweber commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the double-commit bug (#4 from the bug review): -transact! ran the side-effecting, committing transaction inside swap!, so a concurrent update of the conn state (e.g. reset-schema!) could fail the CAS and make swap! re-run the transaction — committing it twice under two different tx ids.

Rather than patching the retry, this makes the bug unrepresentable: since all transacted state lives in the SQLite store, the connection stops being a state container (the atom-conn was a Datascript inheritance, where the atom is the database).

  • Conn is a plain deftype implementing only IDeref — no IAtom, no swap!/reset!.
  • deref derives the current database value from the store (q-max-tx + in-memory template). It takes the conn lock so it never observes another thread's uncommitted transaction through the shared JDBC connection.
  • transact! = lock → with on a freshly derived value → commit → notify listeners. No CAS, no retry path. Composes with the strict snapshot check in with (throws on stale snapshots): a value derived under the write lock always passes it.
  • reset-schema!, listen!/unlisten! update conn-local fields; they can no longer interfere with a running transaction. Schema deliberately stays in-memory (per discussion).
  • -transact! is kept as the no-notify variant used by transact! and the JS API; reset-conn! added as a low-level helper for the JS API.
  • Removes the now-unused extend-clj dependency; updates create-conn/transact! docstrings (dead [[reset-conn!]]/[[db]] links, atom wording).

Regression tests (dbval.test.conn)

  • test-transact!-not-repeated-by-concurrent-conn-update — the Refactor entity and transaction IDs to UUIDs #4 race: reset-schema! during a slow transact! must leave exactly one committed datom (previously two, under different tx ids).
  • test-deref-derives-value-from-store@conn reflects a db-with that bypassed transact!.
  • test-with-rejects-stale-snapshot — locks in the strict with behavior.
  • test-conn-is-not-an-atom — conn no longer satisfies IAtom; conn? is now a strict instance check.

Against the old implementation these produce 3 failures + 1 error; with this branch they pass.

Known limitation (intentionally out of scope)

Deref freshness is scoped to values backed by the same storage connection. A second JDBC connection to the same db-file pins its WAL read snapshot (connections run with autoCommit=false), so it does not see other connections' commits until its read transaction ends. Fixing that needs storage-layer work (ending read transactions on deref, or per-snapshot read connections) — same bucket as the BEGIN IMMEDIATE cross-process hardening discussed earlier.

Follow-up in this PR: db identity instead of content hashing

hash-db/hash-fdb (full-scan content hash with a :hash cache field) and the datom-by-datom equiv-db are removed — same reasoning as dropping clojure.data/diff: they would have to realize a potentially larger-than-memory database. A db value is now identified by (storage connection, :max-tx, schema) — O(1), like Datomic's (store, basis-t) identity. A filtered db is identified by (underlying db, predicate identity). Removing :hash also retires the stale-cached-hash bug when advancing a snapshot via assoc :max-tx.

Semantic change: two databases with equal content but different stores are no longer = (see test-db-value-identity); filtered-db hashes are no longer content-based (see the updated dbval.test.filter hash test).

Follow-up in this PR: DB and FilteredDB are deftypes (opaque handles)

A defrecord always has value semantics — the only question is which ones. After dropping content-based hashing, the honest shape is Datomic's: DB and FilteredDB are now deftypes with plain reference identity. Consequences:

  • d/basis-tx is the new public accessor: compare two snapshots of the same store by comparing their basis, not by =. (= @conn @conn) is only guaranteed while the store hasn't moved (deref returns the same handle then).
  • Keyword access on db values is gone ((:schema db)d/schema, (:max-tx db)d/basis-tx); internal assoc/map->DB record-isms are replaced by db/with-max-tx / plain constructors.
  • Removed with the record shape: defrecord-updatable + its prismatic helpers, db-transient/db-persistent!, restore-db, the unused rollback-report!, a duplicate TxReport defrecord, and FilteredDB's map-interface throw stubs (~200 lines net deletion).
  • test-db-value-identity now pins the Datomic-style contract; test-protocols asserts db values are not maps.

Follow-up in this PR: minimalism pass

Dead code and dependencies removed (d3d8d4d): query_v3 (Datascript's abandoned experiment, ~1,000 lines), the datom comparators orphaned by the clojure.data/diff removal (also the last UUID-broken Integer/compare paths), the mutable idx field on Datom, case-tree/case-pick/vpred leftovers, the broken core/settings, and the hollow Datomic-compat shims tempid/resolve-tempid plus the duplicate Datascript squuid. Dependencies dropped from deps.edn: nippy (never used) and persistent-sorted-set (its array helpers are now the 20-line dbval.arrays namespace).

ClojureScript/JS variant removed (0f466ab): the storage layer is SQLite via JDBC, so the disabled CLJS variant cannot work without a different backend. All #? reader conditionals stripped, every .cljc renamed to .clj, dbval.js/deps.cljs/externs.js/release-js//test/js/ deleted, the CLJS-only macro layer (defn+, if-cljs, patch-tag) removed, and conn/-transact!/conn/reset-conn! (kept only for the JS API) deleted. Recoverable from git history if a CLJS backend ever materializes.

Benchmarks refactored, Datomic comparison dropped (0029a77): bench_datomic/, test_datomic/, the :datomic alias and the wrapper scripts are gone; script/bench.sh runs the renamed dbval.bench.run (it benches dbval, not Datascript). The benches are adapted to the SQLite/UUID/strict-snapshot world — transaction benches replay against a fresh store per iteration (1k people, batch 1), read benches build a 20k store once, lookups use sampled UUIDs / lookup refs. Running them surfaced and fixed a real bug: storing an empty vector as a value NPEd in tuple (regression test added).

Test plan

  • ./script/test_clj.sh: 159 tests, 1036 assertions, 0 failures (tests removed alongside deleted features: defrecord-updatable, query-v3, squuid), 0 failures, 0 errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_014dX8tTR4yFh5SyBGw3atpo

maxweber and others added 6 commits July 15, 2026 06:36
The store is the single source of truth, so the connection stops being a
state container: `deref` derives the current database value by querying
the latest transaction id (`q-max-tx`), and `Conn` only holds
process-local context (db template with schema and caches, listeners).
The schema deliberately stays in-memory.

This fixes a double-commit bug: `-transact!` used to run the
side-effecting, committing transaction inside `swap!`, so a concurrent
update of the conn state (e.g. `reset-schema!`) could fail the CAS and
make `swap!` re-run the transaction — committing it twice under two
different tx ids. Writes are now serialized with `locking` and there is
no retry path.

Also removes the now-unused extend-clj dependency and updates the JS API
to use the new listener/reset helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`hash-db`/`hash-fdb` computed a database's hash from all of its datoms
and `equiv-db` compared databases datom by datom — full scans that would
have to realize a potentially larger-than-memory database (the same
reasoning that removed `clojure.data/diff` support).

A db value is now identified by its storage connection, its basis
(`:max-tx`) and its schema — O(1), like Datomic's (store, basis-t)
identity. A filtered db is identified by the db it filters and its
predicate (compared by identity). The `:hash` cache field disappears
from both records, which also retires the stale-cached-hash bug when a
snapshot was advanced via `assoc :max-tx`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A defrecord always has value semantics — the only question is which ones.
After removing content-based hashing, the honest shape is Datomic's: a
database value is an opaque handle with reference identity, compared via
its basis. `d/basis-tx` is the new public accessor for that; internal
record-isms (keyword access, `assoc :max-tx`, `map->DB`) are replaced by
`db/basis-tx`, `db/db-conn`, `db/with-max-tx` and plain constructors.

Also removes machinery that only existed to support the record shape or
the removed persistent-sorted-set backend: `defrecord-updatable` (and its
prismatic helpers), `db-transient`/`db-persistent!`, `restore-db`, the
unused `rollback-report!`, a duplicate `TxReport` defrecord, and
FilteredDB's map-interface throw stubs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- `query_v3` (975 lines): Datascript's abandoned query-engine experiment,
  never wired to the public API.
- The datom comparators (`cmp-datoms-*`, `defcomp`, `combine-cmp`,
  `diff-sorted`, `cmp`, `value-cmp`, `cmp-attr-quick`): their last caller
  was `clojure.data/diff`. They also still compared UUID entity ids with
  `Integer/compare`, so they were broken anyway.
- `case-tree`/`case-pick`/`vpred` and the unused bindings in `-search`:
  leftovers of the in-memory search implementation.
- The mutable `idx` field on `Datom` (persistent-sorted-set storage
  support) and its protocol methods.
- `nippy`: required, never used.
- `persistent-sorted-set`: only its array helpers were still used; they
  are now the tiny `dbval.arrays` namespace, and the dependency that was
  Datascript's storage heart is gone from deps.edn.
- `core/settings`: read the `:eavt` field that no longer exists.
- Datomic-compat shims hollowed out by UUID ids: `tempid`,
  `resolve-tempid`, and the Datascript `squuid` implementation (tx ids
  use com.yetanalytics/colossal-squuid).
- `empty-db` docstring: documented persistent-sorted-set options instead
  of `:db-file`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The storage layer is SQLite via JDBC, so the CLJS variant (disabled since
the SQLite port) cannot work without an entirely different backend; git
history keeps it recoverable if one ever materializes.

- Delete `dbval.js`, `deps.cljs`, `externs.js`, `release-js/`,
  `test/js/`, `dbval.test.cljs` and the cljs/js test+bench scripts.
- Strip every `#?`/`#?@` reader conditional (keeping the :clj branches)
  and rename all `.cljc` sources and tests to `.clj`.
- Remove the cross-platform macro layer that existed only for CLJS:
  `defn+` (now plain `defn`), `if-cljs`, `cljs-env?`, `patch-tag`.
- Remove `conn/-transact!` and `conn/reset-conn!`, which survived only
  for the JS API.
- Drop the :cljs alias and transit-cljs from deps.edn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove `bench_datomic/`, `test_datomic/`, the :datomic alias and the
  datomic peer dependency, plus the wrapper scripts. `script/bench.sh`
  replaces bench_clj/bench_all; test_all.sh (a shell for the deleted
  cljs/js/datomic runs) is gone — CI runs `test_clj.sh` directly.
- Rename the runner to `dbval.bench.run` (it benched dbval, not
  Datascript) and adapt it to the SQLite/UUID/strict-snapshot world:
  transaction benches replay against a fresh store per iteration with
  `*batch*` 1 over a 1k-people dataset; read benches build a 20k store
  once; entity lookups use sampled UUIDs / lookup refs instead of the
  old integer eids; freeze/thaw dropped with `d/serializable`
  (jsonista/cheshire removed from deps).
- Bugfix found by the bench: storing an empty vector as a value NPEd in
  `tuple` (& rest args are nil for zero components, Tuple.addAll
  requires a List). With regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A comment between `^java.util.List` and the `(or ...)` form left the
`Tuple.addAll` call reflective; bind the argument with the hint instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maxweber
maxweber merged commit 4b747cd into main Jul 15, 2026
1 check passed
@maxweber
maxweber deleted the conn-without-atom branch July 15, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant