From 9eb07d2a1f8fabe5bc49cd10d4e12aac52bfe1ad Mon Sep 17 00:00:00 2001 From: Stan Verberkt Date: Tue, 2 Jun 2026 19:55:30 +0200 Subject: [PATCH 1/4] Add tests for tcp server --- bb.edn | 4 +-- src/server.clj | 4 +++ test/db_test.clj | 2 +- test/server_test.clj | 67 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/server.clj create mode 100644 test/server_test.clj diff --git a/bb.edn b/bb.edn index bc635e3..44bc809 100644 --- a/bb.edn +++ b/bb.edn @@ -2,6 +2,6 @@ :pods {org.babashka/go-sqlite3 {:version "0.3.13"}} :tasks {test {:doc "Run all tests" - :task (do (require 'db-test) - (let [{:keys [fail error]} (clojure.test/run-tests 'db-test)] + :task (do (require 'db-test 'server-test) + (let [{:keys [fail error]} (clojure.test/run-tests 'db-test 'server-test)] (System/exit (+ fail error))))}}} diff --git a/src/server.clj b/src/server.clj new file mode 100644 index 0000000..7878327 --- /dev/null +++ b/src/server.clj @@ -0,0 +1,4 @@ +(ns server) + +(defn start-server! [_port _handler] + (throw (ex-info "not implemented" {}))) diff --git a/test/db_test.clj b/test/db_test.clj index 79e19c3..b3b0d17 100644 --- a/test/db_test.clj +++ b/test/db_test.clj @@ -9,7 +9,7 @@ (pods/load-pod 'org.babashka/go-sqlite3 "0.3.13") (require '[pod.babashka.go-sqlite3 :as sql-pod]) -;; helpers ;; +;; Helpers ;; (defn- fresh-db [] (let [path (str "/tmp/bitten-test-" (System/nanoTime) ".db") diff --git a/test/server_test.clj b/test/server_test.clj new file mode 100644 index 0000000..8e876c8 --- /dev/null +++ b/test/server_test.clj @@ -0,0 +1,67 @@ +(ns server-test + (:require [clojure.test :refer [deftest is]] + [server :as server])) + +;; Helpers ;; + +(defn- connect! [port] + (let [socket (java.net.Socket. "localhost" port)] + {:socket socket + :writer (java.io.BufferedWriter. (java.io.OutputStreamWriter. (.getOutputStream socket))) + :reader (java.io.BufferedReader. (java.io.InputStreamReader. (.getInputStream socket)))})) + +(defn- send-line! [{:keys [writer]} line] + (.write writer line) + (.newLine writer) + (.flush writer)) + +(defn- recv-line! [{:keys [reader]}] + (.readLine reader)) + +(defn- close-conn! [{:keys [socket]}] + (.close socket)) + +(defn- echo-handler [line] (str "echo:" line)) + +;; Tests ;; + +(deftest server-responds-to-a-single-line + ;; Port 0 asks the OS for a free ephemeral port. + (let [srv (server/start-server! 0 echo-handler) + port (.getLocalPort srv)] + (try + (let [conn (connect! port)] + (try + (send-line! conn "hello") + (is (= "echo:hello" (recv-line! conn))) + (finally (close-conn! conn)))) + (finally (.close srv))))) + +(deftest server-handles-multiple-lines-per-connection + ;; All lines written before reading; tests sequential response ordering. + (let [srv (server/start-server! 0 echo-handler) + port (.getLocalPort srv)] + (try + (let [conn (connect! port)] + (try + (send-line! conn "first") + (send-line! conn "second") + (is (= "echo:first" (recv-line! conn))) + (is (= "echo:second" (recv-line! conn))) + (finally (close-conn! conn)))) + (finally (.close srv))))) + +(deftest server-survives-client-disconnect + ;; A client that connects and immediately closes must not crash the accept loop. + ;; A subsequent connection must succeed normally. + (let [srv (server/start-server! 0 echo-handler) + port (.getLocalPort srv)] + (try + (.close (java.net.Socket. "localhost" port)) + (Thread/sleep 50) + (let [conn (connect! port)] + (try + (send-line! conn "after-disconnect") + (is (= "echo:after-disconnect" (recv-line! conn))) + (finally (close-conn! conn)))) + (finally (.close srv))))) From 058e51dc818597121abe87cc4e6a033e4525fe49 Mon Sep 17 00:00:00 2001 From: Stan Verberkt Date: Tue, 2 Jun 2026 22:06:51 +0200 Subject: [PATCH 2/4] Implement server and connection handling with in/out streams --- src/server.clj | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/server.clj b/src/server.clj index 7878327..3906d09 100644 --- a/src/server.clj +++ b/src/server.clj @@ -1,4 +1,40 @@ (ns server) -(defn start-server! [_port _handler] - (throw (ex-info "not implemented" {}))) +(defn- buffered-reader [input-stream] + (-> input-stream java.io.InputStreamReader. java.io.BufferedReader.)) + +(defn- buffered-writer [output-stream] + (-> output-stream java.io.OutputStreamWriter. java.io.BufferedWriter.)) + +(defn- socket-stream [socket direction] + (case direction + :in (-> socket .getInputStream buffered-reader) + :out (-> socket .getOutputStream buffered-writer))) + +(defn- handle-connection! [socket handler] + (with-open [socket socket] + (let [reader (socket-stream socket :in) + writer (socket-stream socket :out)] + (loop [] + (when-let [line (.readLine reader)] + (.write writer (handler line)) + (.newLine writer) + (.flush writer) + (recur)))))) + +(defn start-server! + "Starts a TCP server on port (0 = OS-assigned). Spawns one thread per accepted + connection; each thread calls handler with each newline-delimited line and writes + the returned string as a response line. Returns the ServerSocket; call .close on + it to stop accepting new connections." + [port handler] + (let [server (java.net.ServerSocket. port)] + (future + (try + (loop [] + (let [client (.accept server)] + (future (handle-connection! client handler)) + (recur))) + (catch java.net.SocketException _ + nil))) ; .close on server causes .accept to throw and exit the loop + server)) From fa4b5b329f0e412efe9abd610d69cae9063900eb Mon Sep 17 00:00:00 2001 From: Stan Verberkt Date: Tue, 2 Jun 2026 22:40:27 +0200 Subject: [PATCH 3/4] Basic edn-support for the server --- bb.edn | 4 +-- src/protocol.clj | 10 +++++++ src/server.clj | 32 ++++++++++++++++++++- test/protocol_test.clj | 34 ++++++++++++++++++++++ test/server_test.clj | 65 ++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 src/protocol.clj create mode 100644 test/protocol_test.clj diff --git a/bb.edn b/bb.edn index 44bc809..a3f172c 100644 --- a/bb.edn +++ b/bb.edn @@ -2,6 +2,6 @@ :pods {org.babashka/go-sqlite3 {:version "0.3.13"}} :tasks {test {:doc "Run all tests" - :task (do (require 'db-test 'server-test) - (let [{:keys [fail error]} (clojure.test/run-tests 'db-test 'server-test)] + :task (do (require 'db-test 'server-test 'protocol-test) + (let [{:keys [fail error]} (clojure.test/run-tests 'db-test 'server-test 'protocol-test)] (System/exit (+ fail error))))}}} diff --git a/src/protocol.clj b/src/protocol.clj new file mode 100644 index 0000000..7c0ebcd --- /dev/null +++ b/src/protocol.clj @@ -0,0 +1,10 @@ +(ns protocol) + +(defn parse-request [line] + (try + (or (clojure.edn/read-string line) {:error "empty request"}) + (catch Exception e + {:error (ex-message e)}))) + +(defn serialize-response [response] + (pr-str response)) diff --git a/src/server.clj b/src/server.clj index 3906d09..d5268cb 100644 --- a/src/server.clj +++ b/src/server.clj @@ -1,4 +1,7 @@ -(ns server) +(ns server + (:require [storage] + [db] + [protocol])) (defn- buffered-reader [input-stream] (-> input-stream java.io.InputStreamReader. java.io.BufferedReader.)) @@ -22,6 +25,33 @@ (.flush writer) (recur)))))) +(defn handle-request [backend {:keys [op error] :as request}] + (cond + error + {:status :error :message error} + + (= op :ping) + {:status :ok :data :pong} + + (= op :transact) + {:status :ok :data (db/upsert! backend (:records request))} + + (= op :query) + (let [opts (cond-> {} + (:e request) (assoc :entities #{(:e request)}) + (:as-of-valid request) (assoc :valid-time (:as-of-valid request)))] + {:status :ok :data (db/query backend opts)}) + + :else + {:status :error :message (str "unknown op: " op)})) + +(defn make-edn-handler [backend] + (fn [line] + (-> line + protocol/parse-request + (->> (handle-request backend)) + protocol/serialize-response))) + (defn start-server! "Starts a TCP server on port (0 = OS-assigned). Spawns one thread per accepted connection; each thread calls handler with each newline-delimited line and writes diff --git a/test/protocol_test.clj b/test/protocol_test.clj new file mode 100644 index 0000000..757d48b --- /dev/null +++ b/test/protocol_test.clj @@ -0,0 +1,34 @@ +(ns protocol-test + (:require [clojure.edn :as edn] + [clojure.test :refer [deftest is]] + [protocol :as protocol])) + +;; parse-request ;; + +(deftest parse-request-returns-edn-map + (is (= {:op :transact :facts [{:e "user/1" :a :user/name :v "Alice"}]} + (protocol/parse-request + "{:op :transact :facts [{:e \"user/1\" :a :user/name :v \"Alice\"}]}")))) + +(deftest parse-request-malformed-edn-returns-error + (is (contains? (protocol/parse-request "{") :error))) + +(deftest parse-request-empty-string-returns-error + (is (contains? (protocol/parse-request "") :error))) + +;; serialize-response ;; + +(deftest serialize-response-produces-edn-string + (is (= {:status :ok :data 1} + (edn/read-string (protocol/serialize-response {:status :ok :data 1}))))) + +(deftest serialize-response-round-trips + ;; pr-str map key order is not guaranteed; parse back to verify content + (let [response {:status :ok :data [{:db/entity "user/1" :user/name "Alice"}]}] + (is (= response + (edn/read-string (protocol/serialize-response response)))))) + +(deftest serialize-response-error-round-trips + (let [response {:status :error :message "unknown op: :foo"}] + (is (= response + (edn/read-string (protocol/serialize-response response)))))) diff --git a/test/server_test.clj b/test/server_test.clj index 8e876c8..d816d2c 100644 --- a/test/server_test.clj +++ b/test/server_test.clj @@ -1,6 +1,13 @@ (ns server-test (:require [clojure.test :refer [deftest is]] - [server :as server])) + [babashka.pods :as pods] + [babashka.fs :as fs] + [server :as server] + [storage :as storage] + [sqlite :as sqlite-backend] + [db :as db])) + +(pods/load-pod 'org.babashka/go-sqlite3 "0.3.13") ;; Helpers ;; @@ -23,7 +30,13 @@ (defn- echo-handler [line] (str "echo:" line)) -;; Tests ;; +(defn- fresh-db [] + (let [path (str "/tmp/bitten-server-test-" (System/nanoTime) ".db") + backend (sqlite-backend/->SqliteBackend path)] + (storage/migrate! backend) + backend)) + +;; TCP accept loop tests ;; (deftest server-responds-to-a-single-line ;; Port 0 asks the OS for a free ephemeral port. @@ -65,3 +78,51 @@ (is (= "echo:after-disconnect" (recv-line! conn))) (finally (close-conn! conn)))) (finally (.close srv))))) + +;; handle-request ;; + +(deftest handle-request-ping + (let [backend (fresh-db)] + (try + (is (= {:status :ok :data :pong} + (server/handle-request backend {:op :ping}))) + (finally (fs/delete-if-exists (:db-path backend)))))) + +(deftest handle-request-transact-upserts-records + (let [backend (fresh-db)] + (try + (let [response (server/handle-request backend + {:op :transact + :records [{:db/entity "user/1" :user/name "Alice"}]})] + (is (= :ok (:status response))) + (is (integer? (:data response))) + (let [results (db/query backend {:entities #{"user/1"}})] + (is (= 1 (count results))) + (is (= "Alice" (:user/name (first results)))))) + (finally (fs/delete-if-exists (:db-path backend)))))) + +(deftest handle-request-query-returns-data + (let [backend (fresh-db)] + (try + (storage/insert-facts! backend [{:entity "user/2" :attribute ":user/name" :value "Bob"}]) + (let [response (server/handle-request backend {:op :query :e "user/2"})] + (is (= :ok (:status response))) + (is (= 1 (count (:data response)))) + (is (= "Bob" (:user/name (first (:data response)))))) + (finally (fs/delete-if-exists (:db-path backend)))))) + +(deftest handle-request-unknown-op-returns-error + (let [backend (fresh-db)] + (try + (let [response (server/handle-request backend {:op :frobulate})] + (is (= :error (:status response))) + (is (string? (:message response)))) + (finally (fs/delete-if-exists (:db-path backend)))))) + +(deftest handle-request-parse-error-returns-error + (let [backend (fresh-db)] + (try + (let [response (server/handle-request backend {:error "bad EDN input"})] + (is (= :error (:status response))) + (is (string? (:message response)))) + (finally (fs/delete-if-exists (:db-path backend)))))) From fc1085ef6b07161e42212bcaf8db2632355733cb Mon Sep 17 00:00:00 2001 From: Stan Verberkt Date: Tue, 2 Jun 2026 22:55:11 +0200 Subject: [PATCH 4/4] Add start task and update README and AGENTS --- AGENTS.md | 9 +++++++-- README.md | 44 ++++++++++++++++++++++++++++++++++++++------ bb.edn | 18 ++++++++++++++---- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4e1df73..a4dfc44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,11 +115,16 @@ Storage is abstracted behind a `defprotocol` in `storage.clj`: ## Running the Project ```bash -bb server.clj # start the server -bb test # run all tests +bb start # start the server (default: port 5432, db bitten.db) +PORT=6432 DB_PATH=/tmp/x.db bb start # override port and db path +bb test # run all tests echo '{:op :ping}' | nc localhost 5432 # smoke-test ``` +`bb start` calls `storage/migrate!` on the backend before accepting connections, so the database file is created automatically. The process blocks on `(deref (promise))` and exits on `Ctrl-C`. + +**bb.edn task gotcha** — use `:requires` in the task map rather than `(require ...)` inside the `:task` body. SCI's analysis pass resolves qualified symbols (e.g. `sqlite/->SqliteBackend`) before the `require` call runs, causing an "Unable to resolve symbol" error. `:requires` loads the namespaces before analysis. + ## Communicating Intent Prefer names that communicate *why* over names that are merely short. Brevity that obscures purpose is a bug, not a virtue. diff --git a/README.md b/README.md index ca50134..5bbce76 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Some databases record what is true *right now*. Bitten records: - **Valid time** - when a fact was true in the real world (e.g. a contract started on 1 January, even if you didn't enter it until March). - **Transaction time** - when the fact was written into the database. -This means you can ask questions like *"what did we know about user Alice on 1 June, as of the snapshot we had in September?"* — and get a deterministic answer even after retroactive corrections have been applied. +This means you can ask questions like *"what did we know about user Alice on 1 June, as of the snapshot we had in September?"*, and get a deterministic answer even after retroactive corrections have been applied. Facts are never updated or deleted. Every change is a new row; retractions are explicit. The log is the truth. @@ -24,13 +24,45 @@ To be extended with: a TCP server & EDN over the wire - **[Babashka](https://babashka.org/)** - GraalVM-native Clojure scripting; fast startup, no JVM warm-up. - **SQLite** via the `org.babashka/go-sqlite3` pod - embedded, zero-infrastructure persistence. +- **EDN over TCP** - newline-delimited protocol; any EDN-capable client can talk to the server. -Not yet implemented; -- **EDN over TCP** - simple line-delimited protocol; any EDN-capable client can talk to the server. +## Running -## Developing +```bash +bb start # default: port 5432, db file bitten.db +PORT=6432 bb start # custom port +DB_PATH=/data/my.db bb start # custom db path +``` + +The server prints a ready line and blocks until interrupted (`Ctrl-C`). The database file is created and migrated automatically on first start. + +### Wire protocol + +One EDN map per line in, one EDN map per line out. + +**Transact** - upsert one or more records. Each record is a flat map with `:db/entity` plus attribute keys. Only changed attributes are written. + +```edn +{:op :transact :records [{:db/entity "user/1" :user/name "Alice" :user/email "a@example.com"}]} +;; => {:status :ok :data 1} ; data is the tx-id, nil for a no-op +``` + +**Query** - return live facts for an entity as a flat map. + +```edn +{:op :query :e "user/1"} +{:op :query :e "user/1" :as-of-valid "2024-06-01"} +;; => {:status :ok :data ({:db/entity "user/1" :user/name "Alice" ...})} +``` + +**Ping** + +```edn +{:op :ping} +;; => {:status :ok :data :pong} +``` -TODO +Errors return `{:status :error :message "..."}`. ### Logic layer (`src/db.clj`) @@ -40,7 +72,7 @@ TODO | `upsert!` | Writes only the attributes that changed; optionally retracts attributes absent from the incoming record (`:missing-keys :retract`). | | `retract!` | Retracts all live facts for a seq of entity IDs. Inserts one retraction row per `(entity, attribute, value)` triple. Entities that are already retracted or do not exist are silently skipped. Returns the tx-id, or `nil` for a no-op. | -Storage is abstracted behind an `IStorage` protocol (`src/storage.clj`). The SQLite implementation lives in `src/sqlite.clj`. To add a new backend, implement the three-method protocol in a new file — no changes to `src/db.clj` or the server are needed. +Storage is abstracted behind an `IStorage` protocol (`src/storage.clj`). The SQLite implementation lives in `src/sqlite.clj`. To add a new backend, implement the three-method protocol in a new file. No changes to `src/db.clj` or the server are needed. ## Testing diff --git a/bb.edn b/bb.edn index a3f172c..2a5887e 100644 --- a/bb.edn +++ b/bb.edn @@ -1,7 +1,17 @@ {:paths ["src" "test"] :pods {org.babashka/go-sqlite3 {:version "0.3.13"}} :tasks - {test {:doc "Run all tests" - :task (do (require 'db-test 'server-test 'protocol-test) - (let [{:keys [fail error]} (clojure.test/run-tests 'db-test 'server-test 'protocol-test)] - (System/exit (+ fail error))))}}} + {test {:doc "Run all tests" + :task (do (require 'db-test 'server-test 'protocol-test) + (let [{:keys [fail error]} (clojure.test/run-tests 'db-test 'server-test 'protocol-test)] + (System/exit (+ fail error))))} + + start {:doc "Start the Bitten server (PORT and DB_PATH env vars optional)" + :requires ([server :as server] [sqlite :as sqlite] [storage :as storage]) + :task (let [port (parse-long (or (System/getenv "PORT") "5432")) + db-path (or (System/getenv "DB_PATH") "bitten.db") + backend (sqlite/->SqliteBackend db-path)] + (storage/migrate! backend) + (server/start-server! port (server/make-edn-handler backend)) + (println (str "Bitten listening on port " port " (db: " db-path ")")) + (deref (promise)))}}}