From af85c5e2fb0cebbc0bc1f0b73961ae61212f474a Mon Sep 17 00:00:00 2001 From: Alexey Timin Date: Thu, 26 Feb 2026 11:52:24 +0100 Subject: [PATCH 1/5] implement attachments --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 ++ _codeql_detected_source_root | 1 - src/reduct/bucket.cc | 112 +++++++++++++++++++++++++++++++++ src/reduct/bucket.h | 34 ++++++++++ tests/reduct/entry_api_test.cc | 59 +++++++++++++++++ 6 files changed, 210 insertions(+), 2 deletions(-) delete mode 120000 _codeql_detected_source_root diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e4d14..c9f779a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: - reductstore_version: "main" exclude_api_version_tag: "" - reductstore_version: "latest" - exclude_api_version_tag: "~[1_18]" + exclude_api_version_tag: "~[1_19]" - license_file: "" exclude_license_tag: "~[license]" diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e00ab..aca3973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Add entry attachments API (`WriteAttachments`, `ReadAttachments`, `RemoveAttachments`) for ReductStore API v1.19 + ## 1.18.0 - 2026-02-04 ### Added diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root deleted file mode 120000 index 945c9b4..0000000 --- a/_codeql_detected_source_root +++ /dev/null @@ -1 +0,0 @@ -. \ No newline at end of file diff --git a/src/reduct/bucket.cc b/src/reduct/bucket.cc index 473dc37..793c2da 100644 --- a/src/reduct/bucket.cc +++ b/src/reduct/bucket.cc @@ -181,6 +181,108 @@ class Bucket : public IBucket { return ProcessBatchV2(std::move(callback), BatchType::kUpdate); } + Error WriteAttachments(std::string_view entry_name, + const AttachmentMap& attachments) const noexcept override { + if (attachments.empty()) { + return Error::kOk; + } + + Batch batch; + const auto meta_entry = fmt::format("{}/$meta", entry_name); + auto timestamp = std::chrono::time_point_cast(Time::clock::now()); + for (const auto& [key, payload] : attachments) { + try { + [[maybe_unused]] auto parsed = nlohmann::json::parse(payload); + } catch (const std::exception& ex) { + return Error{.code = -1, .message = ex.what()}; + } + batch.AddRecord(meta_entry, timestamp, payload, "application/json", {{"key", key}}); + timestamp += std::chrono::microseconds(1); + } + + auto [errors, err] = internal::ProcessBatchV2Records(client_.get(), io_path_, std::move(batch), BatchType::kWrite); + if (err) { + return err; + } + + return FirstBatchRecordError(errors); + } + + Result ReadAttachments(std::string_view entry_name) const noexcept override { + AttachmentMap attachments; + Error callback_err = Error::kOk; + const auto meta_entry = fmt::format("{}/$meta", entry_name); + + auto err = QueryV2({meta_entry}, std::nullopt, std::nullopt, {}, [&attachments, &callback_err](const auto& record) { + auto key = record.labels.find("key"); + if (key == record.labels.end()) { + return true; + } + + auto [payload, read_err] = record.ReadAll(); + if (read_err) { + callback_err = std::move(read_err); + return false; + } + + try { + [[maybe_unused]] auto parsed = nlohmann::json::parse(payload); + } catch (const std::exception& ex) { + callback_err = Error{.code = -1, .message = ex.what()}; + return false; + } + + attachments[key->second] = std::move(payload); + return true; + }); + + if (err) { + return {{}, std::move(err)}; + } + + if (callback_err) { + return {{}, std::move(callback_err)}; + } + + return {std::move(attachments), Error::kOk}; + } + + Error RemoveAttachments(std::string_view entry_name, + const std::set& attachment_keys) const noexcept override { + QueryOptions options; + if (!attachment_keys.empty()) { + nlohmann::json when; + when["$in"] = nlohmann::json::array(); + when["$in"].push_back("&key"); + for (const auto& key : attachment_keys) { + when["$in"].push_back(key); + } + options.when = when.dump(); + } + + Batch remove_batch; + const auto meta_entry = fmt::format("{}/$meta", entry_name); + auto query_err = QueryV2({meta_entry}, std::nullopt, std::nullopt, std::move(options), + [&remove_batch](const auto& record) { + auto labels = record.labels; + labels["remove"] = "true"; + remove_batch.AddOnlyLabels(record.entry, record.timestamp, std::move(labels)); + return true; + }); + + if (query_err) { + return query_err; + } + + auto [errors, err] = + internal::ProcessBatchV2Records(client_.get(), io_path_, std::move(remove_batch), BatchType::kUpdate); + if (err) { + return err; + } + + return FirstBatchRecordError(errors); + } + Result RemoveBatch(std::string_view entry_name, BatchCallback callback) const noexcept override { return ProcessBatchV1(entry_name, std::move(callback), BatchType::kRemove); } @@ -655,6 +757,16 @@ class Bucket : public IBucket { return api_version && internal::IsCompatible("1.18", *api_version); } + static Error FirstBatchRecordError(const BatchRecordErrors& errors) { + for (const auto& [entry, record_errors] : errors) { + (void)entry; + if (!record_errors.empty()) { + return record_errors.begin()->second; + } + } + return Error::kOk; + } + Result ProcessBatchV1(std::string_view entry_name, BatchCallback callback, BatchType type) const noexcept { Batch batch; diff --git a/src/reduct/bucket.h b/src/reduct/bucket.h index 1827199..52f30a9 100644 --- a/src/reduct/bucket.h +++ b/src/reduct/bucket.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -84,6 +85,7 @@ class IBucket { }; using LabelMap = std::map; + using AttachmentMap = std::map; // attachment key -> JSON payload /** * ReadableRecord @@ -369,6 +371,38 @@ class IBucket { */ [[nodiscard]] virtual Result UpdateBatch(BatchCallback callback) const noexcept = 0; + /** + * @brief Write JSON attachments for an entry + * + * Attachments are stored in a metadata entry `/$meta`. + * + * @param entry_name entry in bucket + * @param attachments map of attachment keys to JSON payloads + * @return HTTP or communication error + */ + virtual Error WriteAttachments(std::string_view entry_name, + const AttachmentMap& attachments) const noexcept = 0; + + /** + * @brief Read JSON attachments for an entry + * + * @param entry_name entry in bucket + * @return map of attachment keys to JSON payloads + */ + [[nodiscard]] virtual Result ReadAttachments(std::string_view entry_name) const noexcept = 0; + + /** + * @brief Remove JSON attachments for an entry + * + * If attachment_keys is empty, all attachments are removed. + * + * @param entry_name entry in bucket + * @param attachment_keys attachment keys to remove + * @return HTTP or communication error + */ + virtual Error RemoveAttachments(std::string_view entry_name, const std::set& attachment_keys) const + noexcept = 0; + /** * Query options */ diff --git a/tests/reduct/entry_api_test.cc b/tests/reduct/entry_api_test.cc index 2f0b8b8..e3acc94 100644 --- a/tests/reduct/entry_api_test.cc +++ b/tests/reduct/entry_api_test.cc @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include @@ -696,6 +698,63 @@ TEST_CASE("reduct::IBucket should write a batch to multiple entries", "[entry_ap }) == Error::kOk); } +TEST_CASE("reduct::IBucket should write and read entry attachments", "[entry_api][1_19]") { + Fixture ctx; + auto [bucket, _] = ctx.client->CreateBucket(kBucketName); + REQUIRE(bucket); + + IBucket::AttachmentMap attachments{ + {"meta-1", R"({"enabled":true,"values":[1,2,3]})"}, + {"meta-2", R"({"name":"test"})"}, + }; + + REQUIRE(bucket->WriteAttachments("entry-1", attachments) == Error::kOk); + + auto [stored, err] = bucket->ReadAttachments("entry-1"); + REQUIRE(err == Error::kOk); + REQUIRE(stored.size() == 2); + REQUIRE(nlohmann::json::parse(stored.at("meta-1")) == nlohmann::json::parse(attachments.at("meta-1"))); + REQUIRE(nlohmann::json::parse(stored.at("meta-2")) == nlohmann::json::parse(attachments.at("meta-2"))); +} + +TEST_CASE("reduct::IBucket should remove selected entry attachments", "[entry_api][1_19]") { + Fixture ctx; + auto [bucket, _] = ctx.client->CreateBucket(kBucketName); + REQUIRE(bucket); + + IBucket::AttachmentMap attachments{ + {"meta-1", R"({"value":1})"}, + {"meta-2", R"({"value":2})"}, + }; + + REQUIRE(bucket->WriteAttachments("entry-1", attachments) == Error::kOk); + REQUIRE(bucket->RemoveAttachments("entry-1", std::set{"meta-1"}) == Error::kOk); + + auto [stored, err] = bucket->ReadAttachments("entry-1"); + REQUIRE(err == Error::kOk); + REQUIRE(stored.size() == 1); + REQUIRE(stored.contains("meta-2")); + REQUIRE(nlohmann::json::parse(stored.at("meta-2")) == nlohmann::json::parse(attachments.at("meta-2"))); +} + +TEST_CASE("reduct::IBucket should remove all entry attachments", "[entry_api][1_19]") { + Fixture ctx; + auto [bucket, _] = ctx.client->CreateBucket(kBucketName); + REQUIRE(bucket); + + IBucket::AttachmentMap attachments{ + {"meta-1", R"({"value":1})"}, + {"meta-2", R"({"value":2})"}, + }; + + REQUIRE(bucket->WriteAttachments("entry-1", attachments) == Error::kOk); + REQUIRE(bucket->RemoveAttachments("entry-1", {}) == Error::kOk); + + auto [stored, err] = bucket->ReadAttachments("entry-1"); + REQUIRE(err == Error::kOk); + REQUIRE(stored.empty()); +} + TEST_CASE("Batch should slice data", "[batch]") { auto batch = IBucket::Batch(); From bbce28bd39dccb61fa4b381a49dfdc8e213caba4 Mon Sep 17 00:00:00 2001 From: Alexey Timin Date: Thu, 26 Feb 2026 11:53:14 +0100 Subject: [PATCH 2/5] add codex skills --- .codex/skills/create-pr/SKILL.md | 68 +++++++++++++++++++ .../create-pr/scripts/fetch_pr_template.sh | 20 ++++++ 2 files changed, 88 insertions(+) create mode 100644 .codex/skills/create-pr/SKILL.md create mode 100755 .codex/skills/create-pr/scripts/fetch_pr_template.sh diff --git a/.codex/skills/create-pr/SKILL.md b/.codex/skills/create-pr/SKILL.md new file mode 100644 index 0000000..7da2a8f --- /dev/null +++ b/.codex/skills/create-pr/SKILL.md @@ -0,0 +1,68 @@ +--- +name: create-pr +description: Create GitHub pull requests with the gh CLI using the reductstore/.github PR template, then update CHANGELOG.md with the created PR ID and commit the changelog (do not push). Use when a user asks to open a PR and record its ID in the changelog. +--- + +# Create Pr + +## Overview + +Create a PR using the standardized template from reductstore/.github, then record the PR number in CHANGELOG.md and commit the changelog without pushing. + +## Workflow + +### 1) Preconditions +- Ensure `gh auth status` is logged in and the current branch is the intended PR branch. +- Ensure the working tree is clean (or only the intended changes are present). +- Use context from the current conversation to infer intent, scope, and any constraints. + +### 2) Understand changes and rationale +- Compare the current branch against `main` to understand what changed and why (use this to derive the PR title, description, and rationale): + - `git fetch origin main` + - `git log --oneline origin/main..HEAD` + - `git diff --stat origin/main...HEAD` + - `git diff origin/main...HEAD` +- If the branch name starts with an issue number (e.g., `123-...`), use that issue in the rationale and fill the `Closes #` line in the PR template. +- If the branch name does not include an issue number, infer the likely issue from changes and conversation context, or leave `Closes #` empty if unsure. + +### 3) Fetch the PR template +Use the helper script to download the latest PR template from reductstore/.github: + +```bash +./.codex/skills/create-pr/scripts/fetch_pr_template.sh /tmp/pr_template.md +``` + +Fill in the template file with the relevant summary, testing, and rationale derived from the diff and conversation context. + +### 4) Create the PR with gh +Use the filled template as the PR body: + +```bash +gh pr create --title "" --body-file /tmp/pr_template.md +``` + +Capture the PR number after creation: + +```bash +gh pr view --json number -q .number +``` + +### 5) Update CHANGELOG.md +- Find the appropriate section (usually the most recent/unreleased section). +- Add a new entry following the existing style in the file. +- Include the PR ID as `#<number>` exactly as prior entries do. + +### 6) Commit (no push) +Stage and commit the changelog update only: + +```bash +git add CHANGELOG.md +git commit -m "Update changelog for PR #<number>" +``` + +Do not push. + +## Resources + +### scripts/ +- `fetch_pr_template.sh`: Download the latest PR template from reductstore/.github. diff --git a/.codex/skills/create-pr/scripts/fetch_pr_template.sh b/.codex/skills/create-pr/scripts/fetch_pr_template.sh new file mode 100755 index 0000000..cd9730b --- /dev/null +++ b/.codex/skills/create-pr/scripts/fetch_pr_template.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 <output-file>" >&2 + exit 1 +fi + +output_file="$1" + +curl -sS -L \ + "https://raw.githubusercontent.com/reductstore/.github/main/.github/pull_request_template.md" \ + -o "$output_file" + +if [[ ! -s "$output_file" ]]; then + echo "Template download failed or empty: $output_file" >&2 + exit 1 +fi + +echo "Template saved to $output_file" From 0d1811798730fecd760b2d69ebe975e7454a972a Mon Sep 17 00:00:00 2001 From: Alexey Timin <atimin@gmail.com> Date: Thu, 26 Feb 2026 11:55:15 +0100 Subject: [PATCH 3/5] Update changelog for PR #112 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aca3973..05d1263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add entry attachments API (`WriteAttachments`, `ReadAttachments`, `RemoveAttachments`) for ReductStore API v1.19 +- Add entry attachments API (`WriteAttachments`, `ReadAttachments`, `RemoveAttachments`) for ReductStore API v1.19, [PR-112](https://github.com/reductstore/reduct-cpp/pull/112) ## 1.18.0 - 2026-02-04 From c45b62450c7c15296dcd98048a3e973d45a4f8df Mon Sep 17 00:00:00 2001 From: Alexey Timin <atimin@gmail.com> Date: Thu, 26 Feb 2026 13:06:33 +0100 Subject: [PATCH 4/5] Fix test target linkage for nlohmann_json --- tests/CMakeLists.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d330496..b14fdaf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,7 +22,11 @@ add_executable(reduct-tests ${SRC_FILES}) target_include_directories(reduct-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries( reduct-tests - PRIVATE ${RCPP_TARGET_NAME} fmt::fmt Catch2::Catch2 + PRIVATE + ${RCPP_TARGET_NAME} + fmt::fmt + nlohmann_json::nlohmann_json + Catch2::Catch2 ) set_target_properties( reduct-tests From be6bb563340ef7c782f31e277f94ed35063eae2a Mon Sep 17 00:00:00 2001 From: Alexey Timin <atimin@gmail.com> Date: Thu, 26 Feb 2026 13:14:08 +0100 Subject: [PATCH 5/5] Remove license tests from CI and test suite --- .github/actions/run-tests/action.yml | 4 ---- .github/workflows/ci.yml | 9 +-------- tests/reduct/server_api_test.cc | 15 --------------- 3 files changed, 1 insertion(+), 27 deletions(-) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 8802d49..4891e29 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -13,9 +13,6 @@ inputs: description: "Reduct Store version" required: false default: "main" - lic_file: # id of input - description: "License file" - required: true runs: using: "composite" steps: @@ -23,7 +20,6 @@ runs: shell: bash run: docker run -p 8383:8383 -v ${PWD}:/workdir --env RS_API_TOKEN=${{inputs.api-token}} - --env RS_LICENSE_PATH=/workdir/${{inputs.lic_file}} --env RS_EXT_PATH=/tmp --name reduct-store -d reduct/store:${{inputs.reductstore-version}} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9f779a..d91d4c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,6 @@ jobs: matrix: token: [ "", "TOKEN" ] reductstore_version: [ "main", "latest" ] - license_file: [ "", "lic.key" ] include: - token: "" exclude_token_api_tag: "~[token_api]" @@ -73,8 +72,6 @@ jobs: exclude_api_version_tag: "" - reductstore_version: "latest" exclude_api_version_tag: "~[1_19]" - - license_file: "" - exclude_license_tag: "~[license]" steps: - uses: actions/checkout@v4 @@ -87,15 +84,11 @@ jobs: - name: Set execute permissions run: chmod +x build/Release/bin/reduct-tests - - name: Generate license - run: echo '${{secrets.LICENSE_KEY}}' > lic.key - - uses: ./.github/actions/run-tests with: api-token: ${{matrix.token}} - tags: "${{matrix.exclude_token_api_tag}} ${{matrix.exclude_api_version_tag}} ${{matrix.exclude_license_tag}}" + tags: "${{matrix.exclude_token_api_tag}} ${{matrix.exclude_api_version_tag}}" reductstore-version: ${{matrix.reductstore_version}} - lic_file: ${{matrix.license_file}} check-example-linux: diff --git a/tests/reduct/server_api_test.cc b/tests/reduct/server_api_test.cc index a25a137..cb7e990 100644 --- a/tests/reduct/server_api_test.cc +++ b/tests/reduct/server_api_test.cc @@ -33,21 +33,6 @@ TEST_CASE("reduct::Client should get info", "[server_api]") { REQUIRE(*info.defaults.bucket.quota_size == 0); } -TEST_CASE("reduct::Client should get license info", "[server_api][license]") { - Fixture ctx; - auto [info, err] = ctx.client->GetInfo(); - - REQUIRE(err == Error::kOk); - REQUIRE(info.license); - REQUIRE(info.license->licensee == "ReductSoftware"); - REQUIRE(info.license->invoice == "---"); - REQUIRE(info.license->expiry_date.time_since_epoch().count() == 1778852143000000000); - REQUIRE(info.license->plan == "STANDARD"); - REQUIRE(info.license->device_number == 1); - REQUIRE(info.license->disk_quota == 1); - REQUIRE(info.license->fingerprint == "21e2608b7d47f7fba623d714c3e14b73cd1fe3578f4010ef26bcbedfc42a4c92"); -} - TEST_CASE("reduct::Client should list buckets", "[server_api]") { Fixture ctx; auto [list, err] = ctx.client->GetBucketList();