diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 713d364..300f765 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: audit_enabled: "false" - reductstore_version: "latest" exclude_api_version_tag: "~[1_19]" - audit_enabled: "true" + audit_enabled: "false" steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ba0e6..3ef3b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## 1.19.1 - 2026-04-21 + +### Fixed + +- Sync query link creation with record identity API: support `record_entry`/`record_timestamp`, keep legacy `record_index` for older APIs, and validate selectors for API v1.19+, [PR-120](https://github.com/reductstore/reduct-cpp/pull/120) + ## 1.19.0 - 2026-04-08 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a4d53e..6cc5465 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.23) set(MAJOR_VERSION 1) set(MINOR_VERSION 19) -set(PATCH_VERSION 0) +set(PATCH_VERSION 1) set(REDUCT_CPP_FULL_VERSION ${MAJOR_VERSION}.${MINOR_VERSION}.${PATCH_VERSION}) project(reductcpp VERSION ${REDUCT_CPP_FULL_VERSION} LANGUAGES CXX) diff --git a/src/reduct/bucket.cc b/src/reduct/bucket.cc index 3ba88c5..7d17f83 100644 --- a/src/reduct/bucket.cc +++ b/src/reduct/bucket.cc @@ -535,10 +535,26 @@ class Bucket : public IBucket { } Result CreateQueryLink(std::string_view entry_name, QueryLinkOptions options) const noexcept override { - auto [json_payload, json_err] = internal::QueryLinkOptionsToJsonString(name_, {std::string(entry_name)}, options); + auto [normalized_options, normalize_err] = + NormalizeAndValidateQueryLinkOptions({std::string(entry_name)}, std::move(options)); + if (normalize_err) { + return {{}, std::move(normalize_err)}; + } + + auto [json_payload, json_err] = + internal::QueryLinkOptionsToJsonString(name_, {std::string(entry_name)}, normalized_options); + if (json_err) { + return {{}, std::move(json_err)}; + } + + const auto selector = normalized_options.record_timestamp + ? std::chrono::duration_cast( + normalized_options.record_timestamp->time_since_epoch()) + .count() + : static_cast(normalized_options.record_index); auto file_name = - options.file_name ? *options.file_name : fmt::format("{}_{}.bin", entry_name, options.record_index); + normalized_options.file_name ? *normalized_options.file_name : fmt::format("{}_{}.bin", entry_name, selector); auto [body, err] = client_->PostWithResponse(fmt::format("/links/{}", file_name), json_payload.dump()); if (err) { return {{}, std::move(err)}; @@ -561,9 +577,24 @@ class Bucket : public IBucket { return {{}, Error{.code = -1, .message = "At least one entry name is required"}}; } - auto [json_payload, json_err] = internal::QueryLinkOptionsToJsonString(name_, entries, options); + auto [normalized_options, normalize_err] = NormalizeAndValidateQueryLinkOptions(entries, std::move(options)); + if (normalize_err) { + return {{}, std::move(normalize_err)}; + } + + auto [json_payload, json_err] = internal::QueryLinkOptionsToJsonString(name_, entries, normalized_options); + if (json_err) { + return {{}, std::move(json_err)}; + } + + const auto selector = normalized_options.record_timestamp + ? std::chrono::duration_cast( + normalized_options.record_timestamp->time_since_epoch()) + .count() + : static_cast(normalized_options.record_index); - auto file_name = options.file_name ? *options.file_name : fmt::format("{}_{}.bin", name_, options.record_index); + auto file_name = + normalized_options.file_name ? *normalized_options.file_name : fmt::format("{}_{}.bin", name_, selector); auto [body, err] = client_->PostWithResponse(fmt::format("/links/{}", file_name), json_payload.dump()); if (err) { return {{}, std::move(err)}; @@ -770,6 +801,59 @@ class Bucket : public IBucket { return api_version && internal::IsCompatible("1.18", *api_version); } + Result EnsureApiVersion() const { + auto api_version = client_->ApiVersion(); + if (api_version.has_value()) { + return {*api_version, Error::kOk}; + } + + auto [_, err] = client_->Get("/info"); + if (err) { + return {{}, std::move(err)}; + } + + api_version = client_->ApiVersion(); + if (!api_version.has_value()) { + return {{}, Error{.code = -1, .message = "Failed to determine ReductStore API version"}}; + } + return {*api_version, Error::kOk}; + } + + static bool HasWildcard(std::string_view entry) { + return entry.find('*') != std::string_view::npos || entry.find('?') != std::string_view::npos || + entry.find('[') != std::string_view::npos; + } + + Result NormalizeAndValidateQueryLinkOptions(const std::vector& entries, + QueryLinkOptions options) const { + if (options.record_timestamp && !options.record_entry) { + if (entries.size() == 1 && !HasWildcard(entries[0])) { + options.record_entry = entries[0]; + } else { + return {{}, Error{.code = -1, .message = "record_entry must be provided with record_timestamp"}}; + } + } + + if (options.record_entry && !options.record_timestamp) { + return {{}, Error{.code = -1, .message = "record_timestamp must be provided with record_entry"}}; + } + + auto [api_version, api_err] = EnsureApiVersion(); + if (api_err) { + return {{}, std::move(api_err)}; + } + + if (internal::IsCompatible("1.19", api_version) && !options.record_entry.has_value()) { + return {{}, + Error{.code = -1, + .message = + "record entry and timestamp must be provided for ReductStore API v1.19+; use " + "record_entry and record_timestamp"}}; + } + + return {std::move(options), Error::kOk}; + } + static Error FirstBatchRecordError(const BatchRecordErrors& errors) { for (const auto& [entry, record_errors] : errors) { (void)entry; diff --git a/src/reduct/bucket.h b/src/reduct/bucket.h index 52f30a9..23db20d 100644 --- a/src/reduct/bucket.h +++ b/src/reduct/bucket.h @@ -543,7 +543,9 @@ class IBucket { std::optional