Răsfoiți Sursa

feat(storage): DocumentStore interface + failing tests

Phase C Stage 2 Task 2.1 — defines the runtime-polymorphic DocumentStore
interface (put/get/del/count/scan/list_collections/drop_collection) plus a
TDD-red test suite covering round-trip, absence, deletion, count,
collection isolation, listing, drop, and basic scan semantics.

LmdbDocumentStore is declared with a stub implementation (every method
throws std::runtime_error) so the test binary links; Task 2.2 fills in
the real impl. Filter / sort / projection parity tests arrive in Task 2.3.

The test currently aborts at runtime (SIGABRT, exit 134) on its first put
— this is the expected TDD-red state for the stage.
fszontagh 2 luni în urmă
părinte
comite
c28043e749

+ 1 - 0
service/CMakeLists.txt

@@ -62,6 +62,7 @@ set(DATABASE_SERVICE_SOURCES
     src/storage/lmdb_env.cpp
     src/storage/lmdb_txn.cpp
     src/storage/lmdb_dbi.cpp
+    src/storage/document_store_lmdb.cpp
 )
 
 # Create executable

+ 72 - 0
service/src/storage/document_store.hpp

@@ -0,0 +1,72 @@
+// v2.0 storage engine — document-level abstraction over the LMDB substrate.
+//
+// DocumentStore is a runtime-polymorphic interface (NOT a template) so that
+// Stage 4's gRPC swap can substitute a different backend without rebuilding
+// the world. Methods take string_view / Document by const& and either return
+// or throw; the Stage 2 implementation in document_store_lmdb.{hpp,cpp} is
+// the production backend.
+//
+// Lifecycle semantics:
+//   - The store is constructed against an existing LmdbEnv (non-owning).
+//     Collections become sub-databases under that env, opened lazily on
+//     first put / read / drop.
+//   - Each interface method takes its own LMDB transaction internally;
+//     the substrate's single-writer / multi-reader contract is exposed at
+//     this layer as "puts / dels serialise globally; reads are concurrent".
+//   - System-reserved sub-db names start with `_`; user collections must not.
+//     list_collections() filters these out.
+
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "document.hpp"
+
+namespace smartbotic::db::storage {
+
+// Per-scan result counters returned alongside the document slice.
+struct ScanResult {
+    std::vector<smartbotic::database::Document> documents;
+    uint64_t total_matched = 0;   // documents matched before limit/offset
+    bool has_more = false;        // true if total_matched > offset + documents.size()
+};
+
+class DocumentStore {
+public:
+    virtual ~DocumentStore() = default;
+
+    // Insert or replace a document. Collection sub-db is created on first put.
+    // Throws std::runtime_error on storage failure.
+    virtual void put(std::string_view collection,
+                     std::string_view id,
+                     const smartbotic::database::Document& doc) = 0;
+
+    // Read a document. Returns nullopt if absent or collection doesn't exist.
+    virtual std::optional<smartbotic::database::Document>
+    get(std::string_view collection, std::string_view id) = 0;
+
+    // Delete a document. Returns true if it existed, false otherwise.
+    virtual bool del(std::string_view collection, std::string_view id) = 0;
+
+    // Document count for a collection. Returns 0 if collection doesn't exist.
+    virtual uint64_t count(std::string_view collection) = 0;
+
+    // Scan with filtering, sorting, projection, pagination.
+    // Filter / sort / projection semantics match v1.x exactly — see
+    // service/src/memory_store.cpp::find for the reference behaviour.
+    virtual ScanResult scan(std::string_view collection,
+                            const smartbotic::database::Query& query) = 0;
+
+    // List user-visible collection names (sub-dbs that don't start with `_`).
+    // Sorted alphabetically. Empty if no collections exist yet.
+    virtual std::vector<std::string> list_collections() = 0;
+
+    // Drop a collection sub-db entirely. Returns true if it existed.
+    virtual bool drop_collection(std::string_view collection) = 0;
+};
+
+}  // namespace smartbotic::db::storage

+ 61 - 0
service/src/storage/document_store_lmdb.cpp

@@ -0,0 +1,61 @@
+// v2.0 storage engine — LMDB-backed DocumentStore implementation.
+//
+// Task 2.1 — stub-only: every operation throws std::runtime_error so the
+// test binary links and the tests fail at runtime (TDD RED). Task 2.2 fills
+// in the real implementation.
+
+#include "storage/document_store_lmdb.hpp"
+
+#include <stdexcept>
+
+#include "storage/lmdb_env.hpp"
+
+namespace smartbotic::db::storage {
+
+LmdbDocumentStore::LmdbDocumentStore(LmdbEnv& env) noexcept : env_(env) {}
+
+void LmdbDocumentStore::put(std::string_view, std::string_view,
+                             const smartbotic::database::Document&) {
+    throw std::runtime_error("LmdbDocumentStore::put not implemented (Task 2.2)");
+}
+
+std::optional<smartbotic::database::Document>
+LmdbDocumentStore::get(std::string_view, std::string_view) {
+    throw std::runtime_error("LmdbDocumentStore::get not implemented (Task 2.2)");
+}
+
+bool LmdbDocumentStore::del(std::string_view, std::string_view) {
+    throw std::runtime_error("LmdbDocumentStore::del not implemented (Task 2.2)");
+}
+
+uint64_t LmdbDocumentStore::count(std::string_view) {
+    throw std::runtime_error("LmdbDocumentStore::count not implemented (Task 2.2)");
+}
+
+ScanResult LmdbDocumentStore::scan(std::string_view,
+                                    const smartbotic::database::Query&) {
+    throw std::runtime_error("LmdbDocumentStore::scan not implemented (Task 2.2)");
+}
+
+std::vector<std::string> LmdbDocumentStore::list_collections() {
+    throw std::runtime_error(
+        "LmdbDocumentStore::list_collections not implemented (Task 2.2)");
+}
+
+bool LmdbDocumentStore::drop_collection(std::string_view) {
+    throw std::runtime_error(
+        "LmdbDocumentStore::drop_collection not implemented (Task 2.2)");
+}
+
+unsigned int LmdbDocumentStore::open_for_write(WriteTxn&, std::string_view) {
+    throw std::runtime_error(
+        "LmdbDocumentStore::open_for_write not implemented (Task 2.2)");
+}
+
+std::optional<unsigned int>
+LmdbDocumentStore::try_open_for_read(ReadTxn&, std::string_view) {
+    throw std::runtime_error(
+        "LmdbDocumentStore::try_open_for_read not implemented (Task 2.2)");
+}
+
+}  // namespace smartbotic::db::storage

+ 79 - 0
service/src/storage/document_store_lmdb.hpp

@@ -0,0 +1,79 @@
+// v2.0 storage engine — LMDB-backed DocumentStore implementation.
+//
+// Maps each collection to one LMDB sub-database. Sub-db handles are cached
+// per-collection on first open (write txn, MDB_CREATE) and reused across
+// subsequent operations on that collection.
+//
+// On-disk encoding: each Document is serialised as JSON text (Document::
+// toJson().dump()) under key = doc id, parsed back via parse_to_nlohmann +
+// Document::fromJson on get / scan. This honours the Phase B binary doc
+// substrate via the toJson()/fromJson() helpers — no direct access to
+// data_binary at this layer.
+
+#pragma once
+
+#include <mutex>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <vector>
+
+#include "storage/document_store.hpp"
+
+namespace smartbotic::db::storage {
+
+class LmdbEnv;
+
+class LmdbDocumentStore : public DocumentStore {
+public:
+    explicit LmdbDocumentStore(LmdbEnv& env) noexcept;
+    ~LmdbDocumentStore() override = default;
+
+    LmdbDocumentStore(const LmdbDocumentStore&) = delete;
+    LmdbDocumentStore& operator=(const LmdbDocumentStore&) = delete;
+    LmdbDocumentStore(LmdbDocumentStore&&) = delete;
+    LmdbDocumentStore& operator=(LmdbDocumentStore&&) = delete;
+
+    void put(std::string_view collection,
+             std::string_view id,
+             const smartbotic::database::Document& doc) override;
+
+    std::optional<smartbotic::database::Document>
+    get(std::string_view collection, std::string_view id) override;
+
+    bool del(std::string_view collection, std::string_view id) override;
+
+    uint64_t count(std::string_view collection) override;
+
+    ScanResult scan(std::string_view collection,
+                    const smartbotic::database::Query& query) override;
+
+    std::vector<std::string> list_collections() override;
+
+    bool drop_collection(std::string_view collection) override;
+
+private:
+    // Sub-db handle cache. MDB_dbi is typedef'd to unsigned int — held as
+    // that bare type to avoid pulling <lmdb.h> into this header.
+    //
+    // Cache invalidation: drop_collection() erases its entry under the
+    // cache mutex; subsequent puts re-create the sub-db on demand.
+    LmdbEnv& env_;
+    std::mutex cache_mutex_;
+    std::unordered_map<std::string, unsigned int> dbi_cache_;
+
+    // Resolve a collection name to an MDB_dbi handle.
+    //
+    // open_for_write: opens (and MDB_CREATE-creates if absent) the sub-db
+    // within the provided write txn, caches the handle, returns it.
+    //
+    // try_open_for_read: opens the sub-db within the provided read txn IF
+    // it already exists. Returns nullopt if the sub-db doesn't exist —
+    // because read txns can't MDB_CREATE.
+    unsigned int open_for_write(class WriteTxn& wtxn, std::string_view collection);
+    std::optional<unsigned int> try_open_for_read(class ReadTxn& rtxn,
+                                                   std::string_view collection);
+};
+
+}  // namespace smartbotic::db::storage

+ 30 - 0
tests/CMakeLists.txt

@@ -343,6 +343,35 @@ target_include_directories(test_lmdb_env PRIVATE
 
 target_link_libraries(test_lmdb_env PRIVATE ${LMDB_LIBRARY})
 
+# DocumentStore + LmdbDocumentStore unit test (v2.0 storage Phase C Stage 2).
+# Same shape as test_lmdb_env: links the storage/ sources and a couple of
+# helpers (doc_binary, json_parse) directly. No dependency on the full
+# service executable.
+add_executable(test_document_store
+    test_document_store.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_env.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_txn.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_dbi.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/document_store_lmdb.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/json_parse.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/doc_binary.cpp
+)
+
+target_include_directories(test_document_store PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
+    ${LMDB_INCLUDE_DIR}
+    ${yyjson_INCLUDE_DIRS}
+)
+
+target_link_libraries(test_document_store PRIVATE ${LMDB_LIBRARY})
+target_link_libraries(test_document_store PRIVATE ${yyjson_LIBRARIES})
+
+if(TARGET nlohmann_json::nlohmann_json)
+    target_link_libraries(test_document_store PRIVATE nlohmann_json::nlohmann_json)
+else()
+    target_include_directories(test_document_store PRIVATE ${NLOHMANN_JSON_INCLUDE_DIRS})
+endif()
+
 # Register with CTest
 enable_testing()
 add_test(NAME vector_storage COMMAND test_vector_storage)
@@ -354,3 +383,4 @@ add_test(NAME eviction COMMAND test_eviction)
 add_test(NAME json_parse COMMAND test_json_parse)
 add_test(NAME doc_binary COMMAND test_doc_binary)
 add_test(NAME lmdb_env COMMAND test_lmdb_env)
+add_test(NAME document_store COMMAND test_document_store)

+ 368 - 0
tests/test_document_store.cpp

@@ -0,0 +1,368 @@
+// v2.0 storage engine — unit tests for DocumentStore (Task 2.1 / 2.2 / 2.3).
+//
+// Each test builds a fresh LmdbEnv in a per-process tmpdir, constructs an
+// LmdbDocumentStore over it, exercises the interface contract, and asserts.
+// Tests are TDD-first: Task 2.1 lands the round-trip / absence / collection
+// suite, Task 2.2 makes them pass, Task 2.3 extends with the filter/sort/
+// projection parity suite.
+
+#include <algorithm>
+#include <atomic>
+#include <cassert>
+#include <cstdlib>
+#include <cstring>
+#include <filesystem>
+#include <iostream>
+#include <optional>
+#include <stdexcept>
+#include <string>
+#include <string_view>
+#include <unistd.h>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "document.hpp"
+#include "storage/document_store.hpp"
+#include "storage/document_store_lmdb.hpp"
+#include "storage/lmdb_env.hpp"
+
+namespace fs = std::filesystem;
+
+using smartbotic::database::Document;
+using smartbotic::database::Query;
+using smartbotic::db::storage::LmdbDocumentStore;
+using smartbotic::db::storage::LmdbEnv;
+using smartbotic::db::storage::LmdbEnvOpts;
+
+namespace {
+
+void check(bool cond, const char* msg) {
+    if (!cond) {
+        std::cerr << "FAIL: " << msg << "\n";
+        std::abort();
+    }
+}
+
+std::string make_tmpdir(const char* tag) {
+    static std::atomic<int> counter{0};
+    int n = counter.fetch_add(1);
+    std::string path = "/tmp/document-store-test-" + std::to_string(::getpid()) +
+                       "-" + std::to_string(n) + "-" + tag;
+    std::error_code ec;
+    fs::remove_all(path, ec);
+    return path;
+}
+
+struct TmpEnv {
+    std::string path;
+    LmdbEnv env;
+    explicit TmpEnv(const char* tag)
+        : path(make_tmpdir(tag)),
+          env(LmdbEnvOpts{path, 64ULL << 20, 256, 126, false}) {}
+    ~TmpEnv() {
+        std::error_code ec;
+        fs::remove_all(path, ec);
+    }
+    TmpEnv(const TmpEnv&) = delete;
+    TmpEnv& operator=(const TmpEnv&) = delete;
+};
+
+// Build a minimal Document for round-trip tests.
+Document make_doc(std::string_view id,
+                  std::string_view collection,
+                  const nlohmann::json& data,
+                  uint64_t ts = 1731628800123ULL) {
+    Document d;
+    d.id = std::string(id);
+    d.collection = std::string(collection);
+    d.set_data(data);
+    d.createdAt = ts;
+    d.updatedAt = ts;
+    d.version = 1;
+    d.lastAccessedAt = ts;
+    return d;
+}
+
+// Build a zoe-shape document: _id, _created_at, name, tags, _vector, meta.
+Document make_zoe_doc(std::string_view id, std::string_view name,
+                      const std::vector<std::string>& tags,
+                      double score) {
+    nlohmann::json data = {
+        {"_id", std::string(id)},
+        {"name", std::string(name)},
+        {"tags", tags},
+        {"_vector", nlohmann::json::array({0.1, 0.2, 0.3, 0.4})},
+        {"meta", {
+            {"score", score},
+            {"nested", {{"deep", "value"}}}
+        }}
+    };
+    return make_doc(id, "zoe", data);
+}
+
+// =========================================================================
+// Task 2.1 — round-trip / absence / count / collection / scan basics
+// =========================================================================
+
+void test_put_get_roundtrip_minimal_doc() {
+    TmpEnv t("roundtrip-min");
+    LmdbDocumentStore store(t.env);
+
+    Document d = make_doc("doc-1", "coll", nlohmann::json{{"a", 1}, {"b", "two"}});
+    store.put("coll", "doc-1", d);
+
+    auto got = store.get("coll", "doc-1");
+    check(got.has_value(), "doc exists after put");
+    check(got->id == "doc-1", "round-trip id");
+    check(got->collection == "coll", "round-trip collection");
+    check(got->createdAt == 1731628800123ULL, "round-trip createdAt");
+    check(got->version == 1, "round-trip version");
+    const auto& data = got->data();
+    check(data["a"] == 1, "round-trip data.a");
+    check(data["b"] == "two", "round-trip data.b");
+}
+
+void test_put_get_roundtrip_zoe_shape_doc() {
+    TmpEnv t("roundtrip-zoe");
+    LmdbDocumentStore store(t.env);
+
+    Document d = make_zoe_doc("zoe-1", "Zoe", {"hot", "new"}, 9.5);
+    store.put("zoe", "zoe-1", d);
+
+    auto got = store.get("zoe", "zoe-1");
+    check(got.has_value(), "zoe doc exists after put");
+    const auto& data = got->data();
+    check(data["name"] == "Zoe", "round-trip data.name");
+    check(data["_vector"].is_array() && data["_vector"].size() == 4,
+          "round-trip _vector preserved");
+    check(data["tags"].is_array() && data["tags"][0] == "hot",
+          "round-trip tags[0]");
+    check(data["meta"]["score"] == 9.5, "round-trip meta.score");
+    check(data["meta"]["nested"]["deep"] == "value",
+          "round-trip nested deep field");
+}
+
+void test_put_overwrite_replaces() {
+    TmpEnv t("overwrite");
+    LmdbDocumentStore store(t.env);
+
+    store.put("c", "k", make_doc("k", "c", nlohmann::json{{"v", 1}}));
+    store.put("c", "k", make_doc("k", "c", nlohmann::json{{"v", 2}}));
+
+    auto got = store.get("c", "k");
+    check(got.has_value(), "doc exists after overwrite");
+    check(got->data()["v"] == 2, "overwrite replaces value");
+    check(store.count("c") == 1, "overwrite keeps count at 1");
+}
+
+void test_get_missing_returns_nullopt() {
+    TmpEnv t("missing");
+    LmdbDocumentStore store(t.env);
+
+    store.put("c", "exists", make_doc("exists", "c", nlohmann::json{{"v", 1}}));
+    auto g = store.get("c", "absent");
+    check(!g.has_value(), "get on missing id returns nullopt");
+}
+
+void test_get_from_nonexistent_collection_returns_nullopt() {
+    TmpEnv t("missing-coll");
+    LmdbDocumentStore store(t.env);
+
+    auto g = store.get("never-created", "any-id");
+    check(!g.has_value(), "get on nonexistent collection returns nullopt");
+}
+
+void test_del_missing_returns_false() {
+    TmpEnv t("del-missing");
+    LmdbDocumentStore store(t.env);
+
+    // Both nonexistent collection and nonexistent id within a real collection.
+    check(!store.del("never-created", "x"),
+          "del on nonexistent collection returns false");
+    store.put("c", "real", make_doc("real", "c", nlohmann::json{{"v", 1}}));
+    check(!store.del("c", "absent"),
+          "del on absent id within existing collection returns false");
+}
+
+void test_del_then_get_returns_nullopt() {
+    TmpEnv t("del-then-get");
+    LmdbDocumentStore store(t.env);
+
+    store.put("c", "k", make_doc("k", "c", nlohmann::json{{"v", 1}}));
+    check(store.del("c", "k"), "del of present doc returns true");
+
+    auto g = store.get("c", "k");
+    check(!g.has_value(), "after del, get returns nullopt");
+}
+
+void test_del_returns_true_for_existing() {
+    TmpEnv t("del-existing");
+    LmdbDocumentStore store(t.env);
+
+    store.put("c", "a", make_doc("a", "c", nlohmann::json{{"v", 1}}));
+    store.put("c", "b", make_doc("b", "c", nlohmann::json{{"v", 2}}));
+    check(store.del("c", "a"), "del of existing returns true");
+    check(store.count("c") == 1, "count drops to 1 after del");
+}
+
+void test_count_after_inserts() {
+    TmpEnv t("count");
+    LmdbDocumentStore store(t.env);
+
+    for (int i = 0; i < 7; ++i) {
+        std::string id = "d" + std::to_string(i);
+        store.put("c", id, make_doc(id, "c", nlohmann::json{{"i", i}}));
+    }
+    check(store.count("c") == 7, "count reports 7 after 7 inserts");
+}
+
+void test_count_zero_for_empty_collection() {
+    TmpEnv t("count-empty");
+    LmdbDocumentStore store(t.env);
+
+    store.put("c", "k", make_doc("k", "c", nlohmann::json{{"v", 1}}));
+    store.del("c", "k");
+    check(store.count("c") == 0, "count is 0 for emptied collection");
+}
+
+void test_count_zero_for_nonexistent_collection() {
+    TmpEnv t("count-nonexistent");
+    LmdbDocumentStore store(t.env);
+
+    check(store.count("never-created") == 0,
+          "count of nonexistent collection is 0");
+}
+
+void test_writes_to_collection_a_invisible_in_collection_b() {
+    TmpEnv t("isolation");
+    LmdbDocumentStore store(t.env);
+
+    store.put("a", "x", make_doc("x", "a", nlohmann::json{{"side", "A"}}));
+    auto from_b = store.get("b", "x");
+    check(!from_b.has_value(), "writes to a are invisible from b");
+    check(store.count("b") == 0, "b is empty when only a was written");
+}
+
+void test_same_id_in_two_collections_are_independent() {
+    TmpEnv t("same-id");
+    LmdbDocumentStore store(t.env);
+
+    store.put("a", "shared", make_doc("shared", "a", nlohmann::json{{"v", "A"}}));
+    store.put("b", "shared", make_doc("shared", "b", nlohmann::json{{"v", "B"}}));
+
+    auto ga = store.get("a", "shared");
+    auto gb = store.get("b", "shared");
+    check(ga.has_value() && ga->data()["v"] == "A",
+          "shared id in collection a holds value A");
+    check(gb.has_value() && gb->data()["v"] == "B",
+          "shared id in collection b holds value B");
+}
+
+void test_list_collections_empty_initially() {
+    TmpEnv t("list-empty");
+    LmdbDocumentStore store(t.env);
+
+    auto cols = store.list_collections();
+    check(cols.empty(),
+          "list_collections returns empty on fresh env");
+}
+
+void test_list_collections_after_inserts() {
+    TmpEnv t("list-after-inserts");
+    LmdbDocumentStore store(t.env);
+
+    store.put("alpha", "k", make_doc("k", "alpha", nlohmann::json{{"v", 1}}));
+    store.put("zulu",  "k", make_doc("k", "zulu",  nlohmann::json{{"v", 2}}));
+    store.put("mike",  "k", make_doc("k", "mike",  nlohmann::json{{"v", 3}}));
+
+    auto cols = store.list_collections();
+    check(cols.size() == 3, "list_collections returns 3 names");
+    check(cols[0] == "alpha" && cols[1] == "mike" && cols[2] == "zulu",
+          "list_collections sorted alphabetically");
+}
+
+void test_drop_collection_removes_all_docs() {
+    TmpEnv t("drop");
+    LmdbDocumentStore store(t.env);
+
+    store.put("c", "a", make_doc("a", "c", nlohmann::json{{"v", 1}}));
+    store.put("c", "b", make_doc("b", "c", nlohmann::json{{"v", 2}}));
+    check(store.count("c") == 2, "precondition: 2 docs");
+
+    bool dropped = store.drop_collection("c");
+    check(dropped, "drop_collection returns true on existing collection");
+    check(store.count("c") == 0, "post-drop count is 0");
+    check(!store.get("c", "a").has_value(), "post-drop get returns nullopt");
+
+    // After drop, the collection name should no longer appear.
+    auto cols = store.list_collections();
+    check(std::find(cols.begin(), cols.end(), "c") == cols.end(),
+          "dropped collection not listed");
+}
+
+void test_drop_nonexistent_returns_false() {
+    TmpEnv t("drop-nonexistent");
+    LmdbDocumentStore store(t.env);
+
+    check(!store.drop_collection("never-created"),
+          "drop_collection returns false for absent collection");
+}
+
+void test_scan_returns_all_for_empty_query() {
+    TmpEnv t("scan-empty-query");
+    LmdbDocumentStore store(t.env);
+
+    for (int i = 0; i < 5; ++i) {
+        std::string id = "d" + std::to_string(i);
+        store.put("c", id, make_doc(id, "c", nlohmann::json{{"i", i}}));
+    }
+    Query q;
+    q.limit = 100;
+    auto r = store.scan("c", q);
+    check(r.documents.size() == 5, "scan returns all 5 docs for empty query");
+    check(r.total_matched == 5, "total_matched = 5");
+    check(!r.has_more, "has_more = false when all fit");
+}
+
+void test_scan_returns_empty_for_nonexistent_collection() {
+    TmpEnv t("scan-nonexistent");
+    LmdbDocumentStore store(t.env);
+
+    Query q;
+    auto r = store.scan("never-created", q);
+    check(r.documents.empty(), "scan on nonexistent collection -> empty");
+    check(r.total_matched == 0, "total_matched = 0");
+    check(!r.has_more, "has_more = false");
+}
+
+}  // namespace
+
+int main() {
+    // Task 2.1 — interface contract.
+    test_put_get_roundtrip_minimal_doc();
+    test_put_get_roundtrip_zoe_shape_doc();
+    test_put_overwrite_replaces();
+    test_get_missing_returns_nullopt();
+    test_get_from_nonexistent_collection_returns_nullopt();
+    test_del_missing_returns_false();
+    test_del_then_get_returns_nullopt();
+    test_del_returns_true_for_existing();
+    test_count_after_inserts();
+    test_count_zero_for_empty_collection();
+    test_count_zero_for_nonexistent_collection();
+    test_writes_to_collection_a_invisible_in_collection_b();
+    test_same_id_in_two_collections_are_independent();
+    test_list_collections_empty_initially();
+    test_list_collections_after_inserts();
+    test_drop_collection_removes_all_docs();
+    test_drop_nonexistent_returns_false();
+    test_scan_returns_all_for_empty_query();
+    test_scan_returns_empty_for_nonexistent_collection();
+
+    // Task 2.3 — filter / sort / projection parity is added in a follow-up
+    // commit; the impl-stage Task 2.2 only has to satisfy the 2.1 suite.
+
+    std::cout << "test_document_store: all passed\n";
+    return 0;
+}