Offline-first key-value store with atom interface (swap!, deref, add-watch) that syncs to a SQLite-backed server over Transit. Server (CLJ): - SQLite storage with Nippy serialization preserving all Clojure types - GET /sync?group=G&since=T pull endpoint with prefix-based groups - POST /sync push endpoint with per-document version checking - Conflict detection (stale write rejection) - Token-based auth with per-user group access - CORS support, soft deletes, purge compaction Client (CLJS): - IndexedDB wrapper with Transit serialization - SyncedAtom implementing IAtom (IDeref, ISwap, IReset, IWatchable) - Write-through to IndexedDB on every swap! - Background sync loop (pull + push) with configurable interval - Online/offline detection with reconnect sync - Conflict resolution (accept server value) - ready? channel for initial load - Custom cache atom support (Reagent ratom compatible) 25 tests, 77 assertions across db, transit, server, and auth.
142 lines
5.6 KiB
Clojure
142 lines
5.6 KiB
Clojure
(ns pocketbook.db-test
|
|
(:require [clojure.test :refer [deftest is testing use-fixtures]]
|
|
[pocketbook.db :as db])
|
|
(:import [java.io File]))
|
|
|
|
(def ^:dynamic *ds* nil)
|
|
|
|
(defn- temp-db-path []
|
|
(str (File/createTempFile "pocketbook-test" ".db")))
|
|
|
|
(use-fixtures :each
|
|
(fn [f]
|
|
(let [path (temp-db-path)
|
|
ds (db/open path)]
|
|
(try
|
|
(binding [*ds* ds]
|
|
(f))
|
|
(finally
|
|
(.delete (File. path)))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Basic CRUD
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(deftest upsert-new-doc
|
|
(let [result (db/upsert! *ds* {:id "todo:1"
|
|
:value {:text "Buy milk" :tags #{:groceries}}
|
|
:base-version 0})]
|
|
(is (= :ok (:status result)))
|
|
(is (= 1 (:version result)))
|
|
(let [doc (db/get-doc *ds* "todo:1")]
|
|
(is (= "todo:1" (:id doc)))
|
|
(is (= {:text "Buy milk" :tags #{:groceries}} (:value doc)))
|
|
(is (= 1 (:version doc)))
|
|
(is (false? (:deleted doc))))))
|
|
|
|
(deftest upsert-update-doc
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
|
|
(let [result (db/upsert! *ds* {:id "todo:1" :value {:text "v2"} :base-version 1})]
|
|
(is (= :ok (:status result)))
|
|
(is (= 2 (:version result)))
|
|
(is (= {:text "v2"} (:value (db/get-doc *ds* "todo:1"))))))
|
|
|
|
(deftest upsert-conflict
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
|
|
(let [result (db/upsert! *ds* {:id "todo:1" :value {:text "bad"} :base-version 0})]
|
|
(is (= :conflict (:status result)))
|
|
(is (= 1 (:current-version result)))
|
|
;; Original doc unchanged
|
|
(is (= {:text "v1"} (:value (db/get-doc *ds* "todo:1"))))))
|
|
|
|
(deftest upsert-conflict-stale-version
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "v2"} :base-version 1})
|
|
(let [result (db/upsert! *ds* {:id "todo:1" :value {:text "bad"} :base-version 1})]
|
|
(is (= :conflict (:status result)))
|
|
(is (= 2 (:current-version result)))
|
|
(is (= {:text "v2"} (:value result)))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Delete
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(deftest soft-delete
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
|
|
(let [result (db/delete! *ds* {:id "todo:1" :base-version 1})]
|
|
(is (= :ok (:status result)))
|
|
(is (= 2 (:version result)))
|
|
(let [doc (db/get-doc *ds* "todo:1")]
|
|
(is (true? (:deleted doc)))
|
|
(is (= 2 (:version doc))))))
|
|
|
|
(deftest delete-conflict
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "v1"} :base-version 0})
|
|
(let [result (db/delete! *ds* {:id "todo:1" :base-version 0})]
|
|
(is (= :conflict (:status result)))
|
|
(is (= 1 (:current-version result)))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Queries
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(deftest docs-since-filters-by-group-and-time
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "a"} :base-version 0})
|
|
(Thread/sleep 10)
|
|
(let [t (System/currentTimeMillis)]
|
|
(Thread/sleep 10)
|
|
(db/upsert! *ds* {:id "todo:2" :value {:text "b"} :base-version 0})
|
|
(db/upsert! *ds* {:id "note:1" :value {:text "c"} :base-version 0})
|
|
(let [docs (db/docs-since *ds* "todo" t)]
|
|
(is (= 1 (count docs)))
|
|
(is (= "todo:2" (:id (first docs)))))))
|
|
|
|
(deftest all-docs-excludes-deleted
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "a"} :base-version 0})
|
|
(db/upsert! *ds* {:id "todo:2" :value {:text "b"} :base-version 0})
|
|
(db/delete! *ds* {:id "todo:1" :base-version 1})
|
|
(let [docs (db/all-docs *ds* "todo")]
|
|
(is (= 1 (count docs)))
|
|
(is (= "todo:2" (:id (first docs))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Clojure type preservation
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(deftest preserves-clojure-types
|
|
(let [value {:keyword :hello
|
|
:set #{1 2 3}
|
|
:vec [1 "two" :three]
|
|
:uuid (java.util.UUID/randomUUID)
|
|
:inst #inst "2026-04-04T10:00:00Z"
|
|
:nested {:a {:b {:c 42}}}
|
|
:nil-val nil
|
|
:ratio 22/7
|
|
:symbol 'my-sym}]
|
|
(db/upsert! *ds* {:id "types:1" :value value :base-version 0})
|
|
(let [doc (db/get-doc *ds* "types:1")]
|
|
(is (= (:keyword value) (:keyword (:value doc))))
|
|
(is (= (:set value) (:set (:value doc))))
|
|
(is (= (:vec value) (:vec (:value doc))))
|
|
(is (= (:uuid value) (:uuid (:value doc))))
|
|
(is (= (:inst value) (:inst (:value doc))))
|
|
(is (= (:nested value) (:nested (:value doc))))
|
|
(is (nil? (:nil-val (:value doc))))
|
|
(is (= (:symbol value) (:symbol (:value doc)))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Purge
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(deftest purge-deleted-removes-old
|
|
(db/upsert! *ds* {:id "todo:1" :value {:text "a"} :base-version 0})
|
|
(db/delete! *ds* {:id "todo:1" :base-version 1})
|
|
;; Wait a tick so the updated timestamp is in the past
|
|
(Thread/sleep 20)
|
|
;; Purge with a large max-age window — recent deletes are kept
|
|
(db/purge-deleted! *ds* 999999)
|
|
(is (some? (db/get-doc *ds* "todo:1")))
|
|
;; Purge with a tiny max-age — everything older than 10ms ago is removed
|
|
(db/purge-deleted! *ds* 10)
|
|
(is (nil? (db/get-doc *ds* "todo:1"))))
|