Ver Fonte

Add document version history with restore support

Implements per-document version tracking that saves previous state on
every update and delete. Includes four new gRPC RPCs (GetVersionHistory,
GetDocumentVersion, RestoreVersion, RestoreToDate) with copy-forward
restore semantics that work for both active and deleted documents.

History depth is configurable per-collection via max_versions. Version
history survives restarts through snapshot serialization (format v2) and
WAL replay reconstruction. Client SDK and README updated accordingly.

Also fixes pre-existing StorageReplication -> DatabaseReplication rename
and adds missing abseil link dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ferenc Szontagh há 5 meses atrás
pai
commit
546f4cd835

+ 36 - 1
README.md

@@ -5,6 +5,7 @@ A standalone key-value document database with gRPC API, designed for microservic
 ## Features
 
 - **Document Storage** - JSON document store with collections
+- **Version History** - Per-document version tracking with restore by version or date
 - **Health Check** - gRPC `HealthCheck()` RPC for service status verification
 - **Replication** - Multi-master replication via `DatabaseReplication` gRPC service
 - **Persistence** - WAL (Write-Ahead Log) + snapshots with LZ4 compression
@@ -129,6 +130,35 @@ client.subscribe("users", [](const smartbotic::database::Event& event) {
 });
 ```
 
+### Version History
+
+Every update and delete saves the previous document state to version history. You can browse history, retrieve old versions, and restore documents — including deleted ones.
+
+```cpp
+// Get version history
+auto history = client.getVersionHistory("users", docId);
+for (const auto& ver : history.versions) {
+    std::cout << "v" << ver.version << " at " << ver.timestamp
+              << " by " << ver.updatedBy << std::endl;
+}
+
+// Get a specific old version
+auto oldVer = client.getDocumentVersion("users", docId, 2);
+
+// Restore to a specific version (creates new version with old data)
+uint64_t newVer = client.restoreVersion("users", docId, 2, "admin");
+
+// Restore to what the document looked like at a given time
+auto [restoredFrom, newVersion] = client.restoreToDate("users", docId,
+    1706000000000, "admin");  // timestamp in ms
+
+// Restore a deleted document
+client.remove("users", docId);
+uint64_t restored = client.restoreVersion("users", docId, 1, "admin");
+```
+
+History depth is configurable per-collection via `max_versions` (0 = unlimited). History survives restarts through snapshots and WAL replay.
+
 ## gRPC API
 
 ### DatabaseService
@@ -142,6 +172,10 @@ client.subscribe("users", [](const smartbotic::database::Event& event) {
 | `Query` | Query documents with filters |
 | `Watch` | Subscribe to document changes |
 | `ListCollections` | List all collections |
+| `GetVersionHistory` | List version history for a document |
+| `GetDocumentVersion` | Get document data at a specific version |
+| `RestoreVersion` | Restore document to a previous version (copy-forward) |
+| `RestoreToDate` | Restore document to version active at a timestamp |
 | `UploadFile` | Upload binary file (streaming) |
 | `DownloadFile` | Download binary file (streaming) |
 | `DeleteFile` | Delete file by ID |
@@ -235,7 +269,8 @@ Create JSON migration files in the migrations directory. Files are processed in
       "collection": "users",
       "options": {
         "encrypted": true,
-        "sensitive_fields": ["password_hash", "api_key"]
+        "sensitive_fields": ["password_hash", "api_key"],
+        "max_versions": 50
       }
     }
   ]

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.0.0
+1.1.0

+ 46 - 1
client/include/smartbotic/database/client.hpp

@@ -92,6 +92,49 @@ public:
      */
     [[nodiscard]] bool exists(const std::string& collection, const std::string& id);
 
+    // ===== Version History =====
+
+    struct VersionEntry {
+        uint64_t version = 0;
+        nlohmann::json data;
+        uint64_t timestamp = 0;
+        std::string updatedBy;
+    };
+
+    struct VersionHistoryResult {
+        std::vector<VersionEntry> versions;
+        uint64_t currentVersion = 0;
+        uint64_t totalCount = 0;
+        bool documentDeleted = false;
+    };
+
+    /**
+     * Get version history for a document.
+     * @param limit 0 = all versions
+     */
+    [[nodiscard]] VersionHistoryResult getVersionHistory(const std::string& collection,
+        const std::string& id, uint32_t limit = 0, uint32_t offset = 0);
+
+    /**
+     * Get a specific historical version of a document.
+     */
+    [[nodiscard]] std::optional<VersionEntry> getDocumentVersion(const std::string& collection,
+        const std::string& id, uint64_t version);
+
+    /**
+     * Restore a document to a previous version (copy-forward).
+     * @return The new version number, or 0 on failure.
+     */
+    uint64_t restoreVersion(const std::string& collection, const std::string& id,
+        uint64_t version, const std::string& actor = "");
+
+    /**
+     * Restore a document to the version active at a given timestamp.
+     * @return {restoredFromVersion, newVersionNumber}, or {0, 0} on failure.
+     */
+    std::pair<uint64_t, uint64_t> restoreToDate(const std::string& collection,
+        const std::string& id, uint64_t timestamp, const std::string& actor = "");
+
     // ===== Query Operations =====
 
     struct QueryOptions {
@@ -133,7 +176,8 @@ public:
 
     // ===== Collection Management =====
 
-    bool createCollection(const std::string& name, uint32_t defaultTtlSeconds = 0, bool encrypted = false);
+    bool createCollection(const std::string& name, uint32_t defaultTtlSeconds = 0,
+                          bool encrypted = false, uint32_t maxVersions = 0);
     bool dropCollection(const std::string& name);
     [[nodiscard]] std::vector<std::string> listCollections();
 
@@ -143,6 +187,7 @@ public:
         uint64_t sizeBytes = 0;
         uint32_t defaultTtlSeconds = 0;
         bool encrypted = false;
+        uint32_t maxVersions = 0;
         uint64_t createdAt = 0;
         uint64_t updatedAt = 0;
     };

+ 152 - 3
client/src/client.cpp

@@ -399,7 +399,8 @@ public:
 
     // ===== Collection Management =====
 
-    bool createCollection(const std::string& name, uint32_t defaultTtlSeconds, bool encrypted) {
+    bool createCollection(const std::string& name, uint32_t defaultTtlSeconds, bool encrypted,
+                          uint32_t maxVersions) {
         smartbotic::databasepb::CreateCollectionRequest request;
         request.set_name(name);
         auto* options = request.mutable_options();
@@ -407,6 +408,9 @@ public:
             options->set_default_ttl_seconds(defaultTtlSeconds);
         }
         options->set_encrypted(encrypted);
+        if (maxVersions > 0) {
+            options->set_max_versions(maxVersions);
+        }
 
         smartbotic::databasepb::CreateCollectionResponse response;
         grpc::ClientContext context;
@@ -479,6 +483,7 @@ public:
         result.sizeBytes = info.size_bytes();
         result.defaultTtlSeconds = info.options().default_ttl_seconds();
         result.encrypted = info.options().encrypted();
+        result.maxVersions = info.options().max_versions();
         result.createdAt = info.created_at();
         result.updatedAt = info.updated_at();
         return result;
@@ -564,6 +569,129 @@ public:
         );
     }
 
+    // ===== Version History =====
+
+    Client::VersionHistoryResult getVersionHistory(const std::string& collection,
+        const std::string& id, uint32_t limit, uint32_t offset) {
+        smartbotic::databasepb::GetVersionHistoryRequest request;
+        request.set_collection(collection);
+        request.set_id(id);
+        if (limit > 0) request.set_limit(limit);
+        if (offset > 0) request.set_offset(offset);
+
+        smartbotic::databasepb::GetVersionHistoryResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->GetVersionHistory(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::getVersionHistory failed: {}", status.error_message());
+            return {};
+        }
+
+        Client::VersionHistoryResult result;
+        result.currentVersion = response.current_version();
+        result.totalCount = response.total_count();
+        result.documentDeleted = response.document_deleted();
+
+        result.versions.reserve(response.versions_size());
+        for (const auto& ver : response.versions()) {
+            Client::VersionEntry entry;
+            entry.version = ver.version();
+            entry.timestamp = ver.timestamp();
+            entry.updatedBy = ver.updated_by();
+            if (!ver.data().empty()) {
+                entry.data = nlohmann::json::parse(ver.data());
+            }
+            result.versions.push_back(std::move(entry));
+        }
+
+        return result;
+    }
+
+    std::optional<Client::VersionEntry> getDocumentVersion(const std::string& collection,
+        const std::string& id, uint64_t version) {
+        smartbotic::databasepb::GetDocumentVersionRequest request;
+        request.set_collection(collection);
+        request.set_id(id);
+        request.set_version(version);
+
+        smartbotic::databasepb::GetDocumentVersionResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->GetDocumentVersion(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::getDocumentVersion failed: {}", status.error_message());
+            return std::nullopt;
+        }
+
+        if (!response.found()) {
+            return std::nullopt;
+        }
+
+        Client::VersionEntry entry;
+        entry.version = response.version_entry().version();
+        entry.timestamp = response.version_entry().timestamp();
+        entry.updatedBy = response.version_entry().updated_by();
+        if (!response.version_entry().data().empty()) {
+            entry.data = nlohmann::json::parse(response.version_entry().data());
+        }
+        return entry;
+    }
+
+    uint64_t restoreVersion(const std::string& collection, const std::string& id,
+        uint64_t version, const std::string& actor) {
+        smartbotic::databasepb::RestoreVersionRequest request;
+        request.set_collection(collection);
+        request.set_id(id);
+        request.set_version(version);
+        if (!actor.empty()) request.set_actor(actor);
+
+        smartbotic::databasepb::RestoreVersionResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->RestoreVersion(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::restoreVersion failed: {}", status.error_message());
+            return 0;
+        }
+
+        if (!response.success()) {
+            spdlog::error("Client::restoreVersion: {}", response.error());
+            return 0;
+        }
+
+        return response.new_version();
+    }
+
+    std::pair<uint64_t, uint64_t> restoreToDate(const std::string& collection,
+        const std::string& id, uint64_t timestamp, const std::string& actor) {
+        smartbotic::databasepb::RestoreToDateRequest request;
+        request.set_collection(collection);
+        request.set_id(id);
+        request.set_timestamp(timestamp);
+        if (!actor.empty()) request.set_actor(actor);
+
+        smartbotic::databasepb::RestoreToDateResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->RestoreToDate(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::restoreToDate failed: {}", status.error_message());
+            return {0, 0};
+        }
+
+        if (!response.success()) {
+            spdlog::error("Client::restoreToDate: {}", response.error());
+            return {0, 0};
+        }
+
+        return {response.restored_version(), response.new_version()};
+    }
+
     // ===== Health =====
 
     bool healthCheck() {
@@ -668,6 +796,26 @@ bool Client::exists(const std::string& collection, const std::string& id) {
     return impl_->exists(collection, id);
 }
 
+Client::VersionHistoryResult Client::getVersionHistory(const std::string& collection,
+    const std::string& id, uint32_t limit, uint32_t offset) {
+    return impl_->getVersionHistory(collection, id, limit, offset);
+}
+
+std::optional<Client::VersionEntry> Client::getDocumentVersion(const std::string& collection,
+    const std::string& id, uint64_t version) {
+    return impl_->getDocumentVersion(collection, id, version);
+}
+
+uint64_t Client::restoreVersion(const std::string& collection, const std::string& id,
+    uint64_t version, const std::string& actor) {
+    return impl_->restoreVersion(collection, id, version, actor);
+}
+
+std::pair<uint64_t, uint64_t> Client::restoreToDate(const std::string& collection,
+    const std::string& id, uint64_t timestamp, const std::string& actor) {
+    return impl_->restoreToDate(collection, id, timestamp, actor);
+}
+
 std::vector<nlohmann::json> Client::find(const std::string& collection,
                                                  const QueryOptions& options) {
     return impl_->find(collection, options);
@@ -702,8 +850,9 @@ bool Client::setIsMember(const std::string& collection, const std::string& setId
     return impl_->setIsMember(collection, setId, member);
 }
 
-bool Client::createCollection(const std::string& name, uint32_t defaultTtlSeconds, bool encrypted) {
-    return impl_->createCollection(name, defaultTtlSeconds, encrypted);
+bool Client::createCollection(const std::string& name, uint32_t defaultTtlSeconds,
+                              bool encrypted, uint32_t maxVersions) {
+    return impl_->createCollection(name, defaultTtlSeconds, encrypted, maxVersions);
 }
 
 bool Client::dropCollection(const std::string& name) {

+ 1 - 0
proto/CMakeLists.txt

@@ -61,6 +61,7 @@ if(TARGET gRPC::grpc++)
     target_link_libraries(smartbotic_db_proto PUBLIC
         protobuf::libprotobuf
         gRPC::grpc++
+        absl::log_internal_check_op
     )
 else()
     target_link_libraries(smartbotic_db_proto PUBLIC

+ 71 - 0
proto/database.proto

@@ -16,6 +16,7 @@ package smartbotic.databasepb;
 // 6007: File too large
 // 6008: Encryption error
 // 6009: Replication error
+// 6010: Version not found
 
 // ===== Main Database Service =====
 
@@ -28,6 +29,12 @@ service DatabaseService {
     rpc Delete(DeleteRequest) returns (DeleteResponse);
     rpc Exists(ExistsRequest) returns (ExistsResponse);
 
+    // Version history operations
+    rpc GetVersionHistory(GetVersionHistoryRequest) returns (GetVersionHistoryResponse);
+    rpc GetDocumentVersion(GetDocumentVersionRequest) returns (GetDocumentVersionResponse);
+    rpc RestoreVersion(RestoreVersionRequest) returns (RestoreVersionResponse);
+    rpc RestoreToDate(RestoreToDateRequest) returns (RestoreToDateResponse);
+
     // Batch operations
     rpc BatchInsert(BatchInsertRequest) returns (BatchInsertResponse);
     rpc BatchGet(BatchGetRequest) returns (BatchGetResponse);
@@ -165,6 +172,69 @@ message ExistsResponse {
     bool exists = 1;
 }
 
+// ===== Version History Operations =====
+
+message DocumentVersionEntry {
+    uint64 version = 1;
+    bytes data = 2;                   // JSON-encoded document data at this version
+    uint64 timestamp = 3;            // When this version was created (updatedAt)
+    string updated_by = 4;
+    bool encrypted = 5;
+    repeated string encrypted_fields = 6;
+}
+
+message GetVersionHistoryRequest {
+    string collection = 1;
+    string id = 2;
+    uint32 limit = 3;                // 0 = all versions
+    uint32 offset = 4;
+}
+
+message GetVersionHistoryResponse {
+    repeated DocumentVersionEntry versions = 1;
+    uint64 current_version = 2;      // 0 if document is deleted
+    uint64 total_count = 3;
+    bool document_deleted = 4;
+}
+
+message GetDocumentVersionRequest {
+    string collection = 1;
+    string id = 2;
+    uint64 version = 3;
+}
+
+message GetDocumentVersionResponse {
+    DocumentVersionEntry version_entry = 1;
+    bool found = 2;
+}
+
+message RestoreVersionRequest {
+    string collection = 1;
+    string id = 2;
+    uint64 version = 3;              // Version number to restore
+    string actor = 4;                // User performing the restore
+}
+
+message RestoreVersionResponse {
+    uint64 new_version = 1;          // The newly created version number
+    bool success = 2;
+    string error = 3;
+}
+
+message RestoreToDateRequest {
+    string collection = 1;
+    string id = 2;
+    uint64 timestamp = 3;            // Find version active at this time (ms since epoch)
+    string actor = 4;
+}
+
+message RestoreToDateResponse {
+    uint64 restored_version = 1;     // Which old version was restored from
+    uint64 new_version = 2;          // The newly created version number
+    bool success = 3;
+    string error = 4;
+}
+
 // ===== Batch Operations =====
 
 message BatchInsertRequest {
@@ -308,6 +378,7 @@ message CollectionOptions {
     uint32 default_ttl_seconds = 2;
     bool encrypted = 3;
     repeated string sensitive_fields = 4;
+    uint32 max_versions = 5;          // Max version history per document (0 = unlimited)
 }
 
 message CreateCollectionResponse {

+ 97 - 0
service/src/database_grpc_impl.cpp

@@ -211,6 +211,88 @@ grpc::Status DatabaseGrpcImpl::Exists(
     return grpc::Status::OK;
 }
 
+// ===== Version History Operations =====
+
+grpc::Status DatabaseGrpcImpl::GetVersionHistory(
+    grpc::ServerContext* /*context*/,
+    const pb::GetVersionHistoryRequest* request,
+    pb::GetVersionHistoryResponse* response
+) {
+    auto result = store_.getVersionHistory(
+        request->collection(), request->id(),
+        request->limit(), request->offset());
+
+    response->set_current_version(result.currentVersion);
+    response->set_total_count(result.totalCount);
+    response->set_document_deleted(result.documentDeleted);
+
+    for (const auto& ver : result.versions) {
+        *response->add_versions() = toProtoVersionEntry(ver);
+    }
+
+    return grpc::Status::OK;
+}
+
+grpc::Status DatabaseGrpcImpl::GetDocumentVersion(
+    grpc::ServerContext* /*context*/,
+    const pb::GetDocumentVersionRequest* request,
+    pb::GetDocumentVersionResponse* response
+) {
+    auto ver = store_.getDocumentAtVersion(
+        request->collection(), request->id(), request->version());
+
+    if (!ver) {
+        response->set_found(false);
+        return grpc::Status::OK;
+    }
+
+    *response->mutable_version_entry() = toProtoVersionEntry(*ver);
+    response->set_found(true);
+
+    return grpc::Status::OK;
+}
+
+grpc::Status DatabaseGrpcImpl::RestoreVersion(
+    grpc::ServerContext* /*context*/,
+    const pb::RestoreVersionRequest* request,
+    pb::RestoreVersionResponse* response
+) {
+    uint64_t newVersion = store_.restoreToVersion(
+        request->collection(), request->id(),
+        request->version(), request->actor());
+
+    if (newVersion == 0) {
+        response->set_success(false);
+        response->set_error("Version not found");
+        return grpc::Status::OK;
+    }
+
+    response->set_new_version(newVersion);
+    response->set_success(true);
+    return grpc::Status::OK;
+}
+
+grpc::Status DatabaseGrpcImpl::RestoreToDate(
+    grpc::ServerContext* /*context*/,
+    const pb::RestoreToDateRequest* request,
+    pb::RestoreToDateResponse* response
+) {
+    auto [restoredVersion, newVersion] = store_.restoreToDate(
+        request->collection(), request->id(),
+        request->timestamp(), request->actor());
+
+    if (newVersion == 0) {
+        response->set_success(false);
+        response->set_error("No version found at the given timestamp");
+        return grpc::Status::OK;
+    }
+
+    response->set_restored_version(restoredVersion);
+    response->set_new_version(newVersion);
+    response->set_success(true);
+    return grpc::Status::OK;
+}
+
 // ===== Batch Operations =====
 
 grpc::Status DatabaseGrpcImpl::BatchInsert(
@@ -375,6 +457,7 @@ grpc::Status DatabaseGrpcImpl::CreateCollection(
     opts.autoCreateId = true;
     opts.defaultTtlSeconds = request->options().default_ttl_seconds();
     opts.encrypted = request->options().encrypted();
+    opts.maxVersions = request->options().max_versions();
     for (const auto& field : request->options().sensitive_fields()) {
         opts.sensitiveFields.push_back(field);
     }
@@ -710,6 +793,7 @@ pb::CollectionInfo DatabaseGrpcImpl::toProtoCollInfo(const struct CollectionInfo
     proto.mutable_options()->set_auto_create_id(info.options.autoCreateId);
     proto.mutable_options()->set_default_ttl_seconds(info.options.defaultTtlSeconds);
     proto.mutable_options()->set_encrypted(info.options.encrypted);
+    proto.mutable_options()->set_max_versions(info.options.maxVersions);
     for (const auto& field : info.options.sensitiveFields) {
         proto.mutable_options()->add_sensitive_fields(field);
     }
@@ -718,6 +802,19 @@ pb::CollectionInfo DatabaseGrpcImpl::toProtoCollInfo(const struct CollectionInfo
     return proto;
 }
 
+pb::DocumentVersionEntry DatabaseGrpcImpl::toProtoVersionEntry(const DocumentVersion& ver) {
+    pb::DocumentVersionEntry proto;
+    proto.set_version(ver.version);
+    proto.set_data(ver.data.dump());
+    proto.set_timestamp(ver.timestamp);
+    proto.set_updated_by(ver.updatedBy);
+    proto.set_encrypted(ver.encrypted);
+    for (const auto& field : ver.encryptedFields) {
+        proto.add_encrypted_fields(field);
+    }
+    return proto;
+}
+
 std::vector<Filter> DatabaseGrpcImpl::fromProtoFilters(
     const google::protobuf::RepeatedPtrField<pb::Filter>& filters
 ) {

+ 27 - 0
service/src/database_grpc_impl.hpp

@@ -72,6 +72,32 @@ public:
         pb::ExistsResponse* response
     ) override;
 
+    // ===== Version History Operations =====
+
+    grpc::Status GetVersionHistory(
+        grpc::ServerContext* context,
+        const pb::GetVersionHistoryRequest* request,
+        pb::GetVersionHistoryResponse* response
+    ) override;
+
+    grpc::Status GetDocumentVersion(
+        grpc::ServerContext* context,
+        const pb::GetDocumentVersionRequest* request,
+        pb::GetDocumentVersionResponse* response
+    ) override;
+
+    grpc::Status RestoreVersion(
+        grpc::ServerContext* context,
+        const pb::RestoreVersionRequest* request,
+        pb::RestoreVersionResponse* response
+    ) override;
+
+    grpc::Status RestoreToDate(
+        grpc::ServerContext* context,
+        const pb::RestoreToDateRequest* request,
+        pb::RestoreToDateResponse* response
+    ) override;
+
     // ===== Batch Operations =====
 
     grpc::Status BatchInsert(
@@ -217,6 +243,7 @@ private:
     static pb::Document toProto(const Document& doc);
     static Document fromProto(const pb::Document& proto);
     static pb::CollectionInfo toProtoCollInfo(const struct CollectionInfo& info);
+    static pb::DocumentVersionEntry toProtoVersionEntry(const DocumentVersion& ver);
     static std::vector<Filter> fromProtoFilters(const google::protobuf::RepeatedPtrField<pb::Filter>& filters);
     static Query fromProtoQuery(const pb::FindRequest& request);
 

+ 44 - 0
service/src/document.hpp

@@ -95,6 +95,47 @@ struct Document {
     }
 };
 
+/**
+ * Represents a historical snapshot of a document at a specific version.
+ * Stored in version history when a document is updated or deleted.
+ */
+struct DocumentVersion {
+    uint64_t version = 0;                        // Version number
+    nlohmann::json data;                         // Document data at this version
+    uint64_t timestamp = 0;                      // updatedAt when this version was saved
+    std::string updatedBy;                       // Who made this change
+    bool encrypted = false;                      // Whether fields were encrypted
+    std::vector<std::string> encryptedFields;    // Encrypted field paths at time of save
+    uint64_t createdAt = 0;                      // Original document createdAt (for restore after delete)
+    std::string createdBy;                       // Original document createdBy (for restore after delete)
+
+    [[nodiscard]] nlohmann::json toJson() const {
+        nlohmann::json j;
+        j["version"] = version;
+        j["data"] = data;
+        j["timestamp"] = timestamp;
+        j["updatedBy"] = updatedBy;
+        j["encrypted"] = encrypted;
+        j["encryptedFields"] = encryptedFields;
+        j["createdAt"] = createdAt;
+        j["createdBy"] = createdBy;
+        return j;
+    }
+
+    static DocumentVersion fromJson(const nlohmann::json& j) {
+        DocumentVersion v;
+        v.version = j.value("version", 0ULL);
+        v.data = j.value("data", nlohmann::json::object());
+        v.timestamp = j.value("timestamp", 0ULL);
+        v.updatedBy = j.value("updatedBy", "");
+        v.encrypted = j.value("encrypted", false);
+        v.encryptedFields = j.value("encryptedFields", std::vector<std::string>{});
+        v.createdAt = j.value("createdAt", 0ULL);
+        v.createdBy = j.value("createdBy", "");
+        return v;
+    }
+};
+
 /**
  * Options for creating a collection.
  * Named CollectionOptions to avoid conflict with proto-generated type.
@@ -104,6 +145,7 @@ struct CollectionOptions {
     uint32_t defaultTtlSeconds = 0;              // Default TTL for documents (0 = none)
     bool encrypted = false;                      // Encrypt all documents by default
     std::vector<std::string> sensitiveFields;   // Fields to always encrypt
+    uint32_t maxVersions = 0;                    // Max version history per document (0 = unlimited)
 
     [[nodiscard]] nlohmann::json toJson() const {
         nlohmann::json j;
@@ -111,6 +153,7 @@ struct CollectionOptions {
         j["defaultTtlSeconds"] = defaultTtlSeconds;
         j["encrypted"] = encrypted;
         j["sensitiveFields"] = sensitiveFields;
+        j["maxVersions"] = maxVersions;
         return j;
     }
 
@@ -120,6 +163,7 @@ struct CollectionOptions {
         opts.defaultTtlSeconds = j.value("defaultTtlSeconds", 0U);
         opts.encrypted = j.value("encrypted", false);
         opts.sensitiveFields = j.value("sensitiveFields", std::vector<std::string>{});
+        opts.maxVersions = j.value("maxVersions", 0U);
         return opts;
     }
 };

+ 326 - 0
service/src/memory_store.cpp

@@ -246,6 +246,7 @@ bool MemoryStore::update(const std::string& collection, const std::string& id, c
         addToExpirationIndex(*coll, id, updated.expiresAt);
     }
 
+    saveToHistory(*coll, it->second);
     it->second = updated;
     coll->updatedAt = updated.updatedAt;
 
@@ -318,6 +319,7 @@ std::string MemoryStore::upsert(const std::string& collection, Document doc) {
             addToExpirationIndex(*coll, docId, doc.expiresAt);
         }
 
+        saveToHistory(*coll, it->second);
         it->second = doc;
     }
 
@@ -358,6 +360,7 @@ bool MemoryStore::remove(const std::string& collection, const std::string& id) {
         removeFromExpirationIndex(*coll, id, it->second.expiresAt);
     }
 
+    saveToHistory(*coll, it->second);
     coll->documents.erase(it);
     coll->updatedAt = currentTimeMs();
 
@@ -428,6 +431,7 @@ bool MemoryStore::updateIfVersion(const std::string& collection, const std::stri
         addToExpirationIndex(*coll, id, updated.expiresAt);
     }
 
+    saveToHistory(*coll, it->second);
     it->second = updated;
     coll->updatedAt = updated.updatedAt;
 
@@ -629,6 +633,7 @@ bool MemoryStore::setAdd(const std::string& collection, const std::string& setId
         }
     }
 
+    saveToHistory(*coll, it->second);
     members.push_back(member);
     it->second.version++;
     it->second.updatedAt = currentTimeMs();
@@ -679,6 +684,7 @@ bool MemoryStore::setRemove(const std::string& collection, const std::string& se
         return false;
     }
 
+    saveToHistory(*coll, it->second);
     it->second.data["members"] = newMembers;
     it->second.version++;
     it->second.updatedAt = currentTimeMs();
@@ -848,6 +854,264 @@ MemoryStore::Stats MemoryStore::getStats() const {
     return stats;
 }
 
+// ===== Version History =====
+
+void MemoryStore::saveToHistory(CollectionData& coll, const Document& currentDoc) {
+    DocumentVersion ver;
+    ver.version = currentDoc.version;
+    ver.data = currentDoc.data;
+    ver.timestamp = currentDoc.updatedAt;
+    ver.updatedBy = currentDoc.updatedBy;
+    ver.encrypted = currentDoc.encrypted;
+    ver.encryptedFields = currentDoc.encryptedFields;
+    ver.createdAt = currentDoc.createdAt;
+    ver.createdBy = currentDoc.createdBy;
+
+    auto& history = coll.versionHistory[currentDoc.id];
+    history.push_back(std::move(ver));
+
+    // Prune oldest versions if maxVersions is configured
+    if (coll.options.maxVersions > 0) {
+        while (history.size() > coll.options.maxVersions) {
+            history.pop_front();
+        }
+    }
+}
+
+MemoryStore::VersionHistoryResult MemoryStore::getVersionHistory(
+    const std::string& collection, const std::string& id,
+    uint32_t limit, uint32_t offset) const {
+
+    const CollectionData* coll = getCollection(collection);
+    if (!coll) {
+        return {};
+    }
+
+    std::shared_lock<std::shared_mutex> lock(coll->mutex);
+
+    VersionHistoryResult result;
+
+    // Check if document is currently active
+    auto docIt = coll->documents.find(id);
+    if (docIt != coll->documents.end() && !docIt->second.isExpired()) {
+        result.currentVersion = docIt->second.version;
+    }
+
+    // Get version history
+    auto histIt = coll->versionHistory.find(id);
+    if (histIt == coll->versionHistory.end() || histIt->second.empty()) {
+        // No history — still return current version info if document exists
+        if (docIt == coll->documents.end()) {
+            return result;  // No document, no history
+        }
+        return result;
+    }
+
+    const auto& history = histIt->second;
+    result.totalCount = history.size();
+
+    // Check if document was deleted (has history but no active document)
+    if (docIt == coll->documents.end()) {
+        result.documentDeleted = true;
+    }
+
+    // Apply pagination
+    uint32_t start = std::min(offset, static_cast<uint32_t>(history.size()));
+    uint32_t end = limit > 0
+        ? std::min(start + limit, static_cast<uint32_t>(history.size()))
+        : static_cast<uint32_t>(history.size());
+
+    for (uint32_t i = start; i < end; ++i) {
+        result.versions.push_back(history[i]);
+    }
+
+    return result;
+}
+
+std::optional<DocumentVersion> MemoryStore::getDocumentAtVersion(
+    const std::string& collection, const std::string& id,
+    uint64_t version) const {
+
+    const CollectionData* coll = getCollection(collection);
+    if (!coll) {
+        return std::nullopt;
+    }
+
+    std::shared_lock<std::shared_mutex> lock(coll->mutex);
+
+    // Check if requesting the current live version
+    auto docIt = coll->documents.find(id);
+    if (docIt != coll->documents.end() && docIt->second.version == version) {
+        DocumentVersion ver;
+        ver.version = docIt->second.version;
+        ver.data = docIt->second.data;
+        ver.timestamp = docIt->second.updatedAt;
+        ver.updatedBy = docIt->second.updatedBy;
+        ver.encrypted = docIt->second.encrypted;
+        ver.encryptedFields = docIt->second.encryptedFields;
+        ver.createdAt = docIt->second.createdAt;
+        ver.createdBy = docIt->second.createdBy;
+        return ver;
+    }
+
+    // Search in history
+    auto histIt = coll->versionHistory.find(id);
+    if (histIt == coll->versionHistory.end()) {
+        return std::nullopt;
+    }
+
+    for (const auto& ver : histIt->second) {
+        if (ver.version == version) {
+            return ver;
+        }
+    }
+
+    return std::nullopt;
+}
+
+uint64_t MemoryStore::restoreToVersion(const std::string& collection, const std::string& id,
+                                        uint64_t version, const std::string& actor) {
+    CollectionData* coll = getOrCreateCollection(collection);
+
+    std::unique_lock<std::shared_mutex> lock(coll->mutex);
+
+    // Find the target version in history
+    auto histIt = coll->versionHistory.find(id);
+    if (histIt == coll->versionHistory.end()) {
+        return 0;
+    }
+
+    const DocumentVersion* targetVer = nullptr;
+    for (const auto& ver : histIt->second) {
+        if (ver.version == version) {
+            targetVer = &ver;
+            break;
+        }
+    }
+
+    if (!targetVer) {
+        return 0;
+    }
+
+    auto docIt = coll->documents.find(id);
+    bool wasDeleted = (docIt == coll->documents.end());
+    uint64_t newVersion = 0;
+    Document restoredDoc;
+
+    if (!wasDeleted) {
+        // Active document: save current state to history, then overwrite
+        saveToHistory(*coll, docIt->second);
+
+        newVersion = docIt->second.version + 1;
+        docIt->second.data = targetVer->data;
+        docIt->second.version = newVersion;
+        docIt->second.updatedAt = currentTimeMs();
+        docIt->second.updatedBy = actor;
+        docIt->second.encrypted = targetVer->encrypted;
+        docIt->second.encryptedFields = targetVer->encryptedFields;
+        docIt->second.nodeId = config_.nodeId;
+
+        restoredDoc = docIt->second;
+    } else {
+        // Deleted document: reconstruct from version history
+        // Find the highest version in history to determine new version number
+        uint64_t maxVer = 0;
+        for (const auto& ver : histIt->second) {
+            maxVer = std::max(maxVer, ver.version);
+        }
+        newVersion = maxVer + 1;
+
+        restoredDoc.id = id;
+        restoredDoc.collection = collection;
+        restoredDoc.data = targetVer->data;
+        restoredDoc.version = newVersion;
+        restoredDoc.createdAt = targetVer->createdAt;
+        restoredDoc.createdBy = targetVer->createdBy;
+        restoredDoc.updatedAt = currentTimeMs();
+        restoredDoc.updatedBy = actor;
+        restoredDoc.encrypted = targetVer->encrypted;
+        restoredDoc.encryptedFields = targetVer->encryptedFields;
+        restoredDoc.nodeId = config_.nodeId;
+
+        // Apply default TTL if configured
+        if (coll->options.defaultTtlSeconds > 0) {
+            restoredDoc.setTtlSeconds(coll->options.defaultTtlSeconds);
+            addToExpirationIndex(*coll, id, restoredDoc.expiresAt);
+        }
+
+        coll->documents[id] = restoredDoc;
+    }
+
+    coll->updatedAt = restoredDoc.updatedAt;
+
+    lock.unlock();
+
+    // Update stats
+    {
+        std::lock_guard<std::mutex> statsLock(statsMutex_);
+        if (wasDeleted) {
+            stats_.totalDocuments++;
+            stats_.insertCount++;
+        } else {
+            stats_.updateCount++;
+        }
+    }
+
+    // Emit callbacks
+    EventType eventType = wasDeleted ? EventType::INSERT : EventType::UPDATE;
+    emitPersist(collection, id, restoredDoc, eventType);
+    emitEvent(eventType, collection, id, restoredDoc.data);
+
+    return newVersion;
+}
+
+std::pair<uint64_t, uint64_t> MemoryStore::restoreToDate(
+    const std::string& collection, const std::string& id,
+    uint64_t timestamp, const std::string& actor) {
+
+    const CollectionData* coll = getCollection(collection);
+    if (!coll) {
+        return {0, 0};
+    }
+
+    uint64_t targetVersion = 0;
+
+    {
+        std::shared_lock<std::shared_mutex> lock(coll->mutex);
+
+        // Search version history for the version active at the given timestamp
+        auto histIt = coll->versionHistory.find(id);
+        if (histIt != coll->versionHistory.end()) {
+            for (const auto& ver : histIt->second) {
+                if (ver.timestamp <= timestamp) {
+                    targetVersion = ver.version;
+                } else {
+                    break;  // Versions are ordered oldest-first
+                }
+            }
+        }
+
+        // Also check current document — if its updatedAt <= timestamp, it's the active version
+        auto docIt = coll->documents.find(id);
+        if (docIt != coll->documents.end() && docIt->second.updatedAt <= timestamp) {
+            // Current version was active at the given time; no restore needed
+            // But for consistency with the copy-forward contract, we still restore
+            targetVersion = docIt->second.version;
+        }
+    }
+
+    if (targetVersion == 0) {
+        return {0, 0};
+    }
+
+    uint64_t newVersion = restoreToVersion(collection, id, targetVersion, actor);
+    if (newVersion == 0) {
+        return {0, 0};
+    }
+
+    return {targetVersion, newVersion};
+}
+
 // ===== Snapshot/Recovery Support =====
 
 std::vector<Document> MemoryStore::getAllDocuments(const std::string& collection) const {
@@ -898,6 +1162,68 @@ void MemoryStore::loadDocument(const std::string& collection, Document doc) {
     }
 }
 
+void MemoryStore::loadDocumentWithHistory(const std::string& collection, Document doc) {
+    CollectionData* coll = getOrCreateCollection(collection);
+
+    std::unique_lock<std::shared_mutex> lock(coll->mutex);
+
+    doc.collection = collection;
+
+    // If document already exists, save current version to history before overwriting
+    auto it = coll->documents.find(doc.id);
+    if (it != coll->documents.end()) {
+        saveToHistory(*coll, it->second);
+
+        // Remove old expiration index entry
+        if (it->second.expiresAt > 0) {
+            removeFromExpirationIndex(*coll, doc.id, it->second.expiresAt);
+        }
+
+        // Overwrite (no stats change — doc count stays the same)
+        if (doc.expiresAt > 0) {
+            addToExpirationIndex(*coll, doc.id, doc.expiresAt);
+        }
+        it->second = std::move(doc);
+    } else {
+        // New document — same as loadDocument
+        if (doc.expiresAt > 0) {
+            addToExpirationIndex(*coll, doc.id, doc.expiresAt);
+        }
+        std::string docId = doc.id;
+        coll->documents[docId] = std::move(doc);
+
+        std::lock_guard<std::mutex> statsLock(statsMutex_);
+        stats_.totalDocuments++;
+    }
+}
+
+std::vector<std::pair<std::string, std::deque<DocumentVersion>>>
+MemoryStore::getAllVersionHistory(const std::string& collection) const {
+    const CollectionData* coll = getCollection(collection);
+    if (!coll) {
+        return {};
+    }
+
+    std::shared_lock<std::shared_mutex> lock(coll->mutex);
+
+    std::vector<std::pair<std::string, std::deque<DocumentVersion>>> result;
+    result.reserve(coll->versionHistory.size());
+    for (const auto& [docId, history] : coll->versionHistory) {
+        if (!history.empty()) {
+            result.emplace_back(docId, history);
+        }
+    }
+    return result;
+}
+
+void MemoryStore::loadVersionHistory(const std::string& collection, const std::string& docId,
+                                      std::deque<DocumentVersion> history) {
+    CollectionData* coll = getOrCreateCollection(collection);
+
+    std::unique_lock<std::shared_mutex> lock(coll->mutex);
+    coll->versionHistory[docId] = std::move(history);
+}
+
 void MemoryStore::clear() {
     std::unique_lock<std::shared_mutex> lock(globalMutex_);
     collections_.clear();

+ 65 - 0
service/src/memory_store.hpp

@@ -3,6 +3,7 @@
 #include "document.hpp"
 
 #include <atomic>
+#include <deque>
 #include <functional>
 #include <map>
 #include <memory>
@@ -224,6 +225,48 @@ public:
 
     [[nodiscard]] Stats getStats() const;
 
+    // ===== Version History =====
+
+    struct VersionHistoryResult {
+        std::vector<DocumentVersion> versions;
+        uint64_t currentVersion = 0;    // 0 if document is deleted
+        uint64_t totalCount = 0;
+        bool documentDeleted = false;
+    };
+
+    /**
+     * Get version history for a document.
+     * Returns historical versions (not including current unless deleted).
+     */
+    [[nodiscard]] VersionHistoryResult getVersionHistory(
+        const std::string& collection, const std::string& id,
+        uint32_t limit = 0, uint32_t offset = 0) const;
+
+    /**
+     * Get a specific historical version of a document.
+     * Also returns the current version if it matches.
+     */
+    [[nodiscard]] std::optional<DocumentVersion> getDocumentAtVersion(
+        const std::string& collection, const std::string& id,
+        uint64_t version) const;
+
+    /**
+     * Restore a document to a specific version (copy-forward).
+     * Creates a NEW version with the old version's data.
+     * Works for both active and deleted documents.
+     * @return The new version number, or 0 on failure.
+     */
+    uint64_t restoreToVersion(const std::string& collection, const std::string& id,
+                              uint64_t version, const std::string& actor = "");
+
+    /**
+     * Restore a document to the version active at a given timestamp.
+     * @return {restoredFromVersion, newVersionNumber}, or {0, 0} on failure.
+     */
+    std::pair<uint64_t, uint64_t> restoreToDate(
+        const std::string& collection, const std::string& id,
+        uint64_t timestamp, const std::string& actor = "");
+
     // ===== Snapshot/Recovery Support =====
 
     /**
@@ -236,11 +279,29 @@ public:
      */
     [[nodiscard]] std::vector<std::pair<std::string, CollectionOptions>> getAllCollectionsWithOptions() const;
 
+    /**
+     * Get all version history for a collection (for snapshotting).
+     */
+    [[nodiscard]] std::vector<std::pair<std::string, std::deque<DocumentVersion>>>
+        getAllVersionHistory(const std::string& collection) const;
+
     /**
      * Load a document directly (for recovery, bypasses event callbacks).
      */
     void loadDocument(const std::string& collection, Document doc);
 
+    /**
+     * Load a document during recovery, saving the previous version to history.
+     * Bypasses event callbacks.
+     */
+    void loadDocumentWithHistory(const std::string& collection, Document doc);
+
+    /**
+     * Load version history directly (for snapshot recovery, bypasses callbacks).
+     */
+    void loadVersionHistory(const std::string& collection, const std::string& docId,
+                            std::deque<DocumentVersion> history);
+
     /**
      * Clear all data (for testing/recovery).
      */
@@ -251,6 +312,7 @@ private:
         CollectionOptions options;
         std::unordered_map<std::string, Document> documents;
         std::map<uint64_t, std::unordered_set<std::string>> expirationIndex;  // expiresAt -> ids
+        std::unordered_map<std::string, std::deque<DocumentVersion>> versionHistory;  // docId -> versions (oldest first)
         uint64_t createdAt = 0;
         uint64_t updatedAt = 0;
         mutable std::shared_mutex mutex;
@@ -295,6 +357,9 @@ private:
     // Background expiration thread
     void expirationLoop();
 
+    // Save current document state to version history before overwriting
+    void saveToHistory(CollectionData& coll, const Document& currentDoc);
+
     // Ensure collection exists, returns pointer to collection data
     CollectionData* getOrCreateCollection(const std::string& name);
     const CollectionData* getCollection(const std::string& name) const;

+ 11 - 1
service/src/persistence/persistence_manager.cpp

@@ -101,14 +101,24 @@ bool PersistenceManager::recover(MemoryStore& store) {
         }
 
         // Replay WAL entries after snapshot
+        // INSERT uses loadDocument (no prior version to save).
+        // UPDATE/UPSERT use loadDocumentWithHistory to preserve the prior
+        // document state in version history during replay.
+        // DELETE calls remove() which internally calls saveToHistory.
         uint64_t replayedCount = wal_->replay(snapshotSequence, [&store](const WalEntry& entry) {
             switch (entry.opType) {
                 case WalOpType::INSERT:
+                    if (entry.data) {
+                        Document doc = Document::fromJson(*entry.data);
+                        store.loadDocument(entry.collection, doc);
+                    }
+                    break;
+
                 case WalOpType::UPDATE:
                 case WalOpType::UPSERT:
                     if (entry.data) {
                         Document doc = Document::fromJson(*entry.data);
-                        store.loadDocument(entry.collection, doc);
+                        store.loadDocumentWithHistory(entry.collection, doc);
                     }
                     break;
 

+ 87 - 2
service/src/persistence/snapshot.cpp

@@ -148,7 +148,7 @@ uint64_t SnapshotManager::loadSnapshot(const std::filesystem::path& path, Memory
 
     // Clear store and deserialize
     store.clear();
-    deserializeStore(uncompressedData, store);
+    deserializeStore(uncompressedData, store, header.version);
 
     return header.walSequence;
 }
@@ -370,12 +370,44 @@ std::vector<uint8_t> SnapshotManager::serializeStore(const MemoryStore& store,
             }
             result.insert(result.end(), docJson.begin(), docJson.end());
         }
+
+        // Write version history (v2)
+        auto allHistory = store.getAllVersionHistory(name);
+        uint64_t historyEntryCount = allHistory.size();
+        for (int i = 0; i < 8; ++i) {
+            result.push_back(static_cast<uint8_t>((historyEntryCount >> (i * 8)) & 0xFF));
+        }
+
+        for (const auto& [docId, versions] : allHistory) {
+            // Write doc ID
+            uint16_t docIdLen = static_cast<uint16_t>(docId.size());
+            result.push_back(static_cast<uint8_t>(docIdLen & 0xFF));
+            result.push_back(static_cast<uint8_t>((docIdLen >> 8) & 0xFF));
+            result.insert(result.end(), docId.begin(), docId.end());
+
+            // Write version count
+            uint32_t versionCount = static_cast<uint32_t>(versions.size());
+            for (int i = 0; i < 4; ++i) {
+                result.push_back(static_cast<uint8_t>((versionCount >> (i * 8)) & 0xFF));
+            }
+
+            // Write each version
+            for (const auto& ver : versions) {
+                std::string verJson = ver.toJson().dump();
+                uint32_t verLen = static_cast<uint32_t>(verJson.size());
+                for (int i = 0; i < 4; ++i) {
+                    result.push_back(static_cast<uint8_t>((verLen >> (i * 8)) & 0xFF));
+                }
+                result.insert(result.end(), verJson.begin(), verJson.end());
+            }
+        }
     }
 
     return result;
 }
 
-void SnapshotManager::deserializeStore(const std::vector<uint8_t>& data, MemoryStore& store) const {
+void SnapshotManager::deserializeStore(const std::vector<uint8_t>& data, MemoryStore& store,
+                                       uint32_t snapshotVersion) const {
     size_t offset = 0;
 
     while (offset < data.size()) {
@@ -436,6 +468,59 @@ void SnapshotManager::deserializeStore(const std::vector<uint8_t>& data, MemoryS
                 // Skip invalid document
             }
         }
+
+        // Read version history (v2+)
+        if (snapshotVersion >= 2) {
+            if (offset + 8 > data.size()) break;
+            uint64_t historyEntryCount = 0;
+            for (int i = 0; i < 8; ++i) {
+                historyEntryCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
+            }
+
+            for (uint64_t h = 0; h < historyEntryCount; ++h) {
+                // Read doc ID
+                if (offset + 2 > data.size()) break;
+                uint16_t docIdLen = static_cast<uint16_t>(data[offset]) |
+                                   (static_cast<uint16_t>(data[offset + 1]) << 8);
+                offset += 2;
+
+                if (offset + docIdLen > data.size()) break;
+                std::string docId(reinterpret_cast<const char*>(&data[offset]), docIdLen);
+                offset += docIdLen;
+
+                // Read version count
+                if (offset + 4 > data.size()) break;
+                uint32_t versionCount = 0;
+                for (int i = 0; i < 4; ++i) {
+                    versionCount |= static_cast<uint32_t>(data[offset++]) << (i * 8);
+                }
+
+                // Read each version into a deque
+                std::deque<DocumentVersion> history;
+                for (uint32_t v = 0; v < versionCount; ++v) {
+                    if (offset + 4 > data.size()) break;
+                    uint32_t verLen = 0;
+                    for (int i = 0; i < 4; ++i) {
+                        verLen |= static_cast<uint32_t>(data[offset++]) << (i * 8);
+                    }
+
+                    if (offset + verLen > data.size()) break;
+                    std::string verJson(reinterpret_cast<const char*>(&data[offset]), verLen);
+                    offset += verLen;
+
+                    try {
+                        auto ver = DocumentVersion::fromJson(nlohmann::json::parse(verJson));
+                        history.push_back(std::move(ver));
+                    } catch (const nlohmann::json::exception&) {
+                        // Skip invalid version entry
+                    }
+                }
+
+                if (!history.empty()) {
+                    store.loadVersionHistory(collName, docId, std::move(history));
+                }
+            }
+        }
     }
 }
 

+ 13 - 2
service/src/persistence/snapshot.hpp

@@ -36,6 +36,15 @@ namespace smartbotic::database {
  *     For each document:
  *       - Document JSON length: uint32
  *       - Document JSON: bytes
+ *     (v2+) Version history:
+ *       - History entry count: uint64 (number of doc IDs with history)
+ *       For each doc ID with history:
+ *         - Doc ID length: uint16
+ *         - Doc ID: bytes
+ *         - Version count: uint32
+ *         For each version:
+ *           - Version JSON length: uint32
+ *           - Version JSON: bytes
  *
  * Footer:
  *   - Body checksum: uint32
@@ -43,7 +52,7 @@ namespace smartbotic::database {
 
 struct SnapshotHeader {
     char magic[8] = {'C', 'A', 'L', 'S', 'N', 'A', 'P', '1'};
-    uint32_t version = 1;
+    uint32_t version = 2;
     uint64_t timestamp = 0;
     uint64_t walSequence = 0;
     uint64_t documentCount = 0;
@@ -141,8 +150,10 @@ private:
 
     /**
      * Deserialize bytes into store.
+     * @param snapshotVersion Snapshot format version (for backward compatibility)
      */
-    void deserializeStore(const std::vector<uint8_t>& data, MemoryStore& store) const;
+    void deserializeStore(const std::vector<uint8_t>& data, MemoryStore& store,
+                          uint32_t snapshotVersion = 2) const;
 
     Config config_;
 };