diff --git a/.codex/skills/create-pr/SKILL.md b/.codex/skills/create-pr/SKILL.md
new file mode 100644
index 00000000..7da2a8f8
--- /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 `#` 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 #"
+```
+
+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 00000000..cd9730b3
--- /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 " >&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"
diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml
index 8802d49e..4891e296 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 e2e4d144..d91d4c0d 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]"
@@ -72,9 +71,7 @@ jobs:
- reductstore_version: "main"
exclude_api_version_tag: ""
- reductstore_version: "latest"
- exclude_api_version_tag: "~[1_18]"
- - license_file: ""
- exclude_license_tag: "~[license]"
+ exclude_api_version_tag: "~[1_19]"
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/CHANGELOG.md b/CHANGELOG.md
index 19e00aba..05d12630 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, [PR-112](https://github.com/reductstore/reduct-cpp/pull/112)
+
## 1.18.0 - 2026-02-04
### Added
diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root
deleted file mode 120000
index 945c9b46..00000000
--- 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 473dc37a..793c2da4 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 18271993..52f30a9f 100644
--- a/src/reduct/bucket.h
+++ b/src/reduct/bucket.h
@@ -7,6 +7,7 @@
#include