Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 38 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`)

Expand All @@ -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

Expand Down
18 changes: 14 additions & 4 deletions bb.edn
Original file line number Diff line number Diff line change
@@ -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)
(let [{:keys [fail error]} (clojure.test/run-tests 'db-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)))}}}
10 changes: 10 additions & 0 deletions src/protocol.clj
Original file line number Diff line number Diff line change
@@ -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))
70 changes: 70 additions & 0 deletions src/server.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
(ns server
(:require [storage]
[db]
[protocol]))

(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 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
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))
2 changes: 1 addition & 1 deletion test/db_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
34 changes: 34 additions & 0 deletions test/protocol_test.clj
Original file line number Diff line number Diff line change
@@ -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))))))
128 changes: 128 additions & 0 deletions test/server_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
(ns server-test
(:require [clojure.test :refer [deftest is]]
[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 ;;

(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))

(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.
(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)))))

;; 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))))))
Loading