|
@@ -0,0 +1,1473 @@
|
|
|
|
|
+# vectorapi 0.2.0 — DB 2.11.1 Feature Adoption Implementation Plan
|
|
|
|
|
+
|
|
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
|
+
|
|
|
|
|
+**Goal:** Expose smartbotic-database 2.11.1's secondary indexes, unique constraints, facets, document TTL, relations/referential integrity, read-only lock, and loaded-client-version through the vectorapi REST API and admin UI, so the live DB upgrade can proceed as one coordinated release.
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** Purely additive HTTP surface under the existing `/api/v1` prefix. New handlers `indexes.cpp` and `relations.cpp` follow the established `registerXRoutes(ApiServer&)` pattern and call `d->db.client()` directly, exactly as `collections.cpp` does. Relation names are project-qualified through the existing `qualify()` helper — no per-project client instances. Indexes and relations are read live from the DB; the collection registry is not extended, so there is no second source of truth.
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** C++20, cpp-httplib, nlohmann/json, spdlog, GoogleTest; React 19 + Vite + TS + Tailwind for the UI.
|
|
|
|
|
+
|
|
|
|
|
+**Spec:** `docs/superpowers/specs/2026-08-14-vectorapi-0.2.0-db-features-design.md`
|
|
|
|
|
+
|
|
|
|
|
+## Global Constraints
|
|
|
|
|
+
|
|
|
|
|
+- C++20. Namespace `svapi`. Flat headers, quoted includes. Handlers `throw svapi::ApiError`; the global handler maps to `{error:{code,message}}`.
|
|
|
|
|
+- The DB client is reached via `d->db.client()`. Every collection **and relation** name crossing into it goes through `qualify(project, name)`.
|
|
|
|
|
+- Reserved prefixes stay rejected: a collection or relation name may not start with `_` or `vectorapi_`.
|
|
|
|
|
+- TS: no `any`, no TS `enum`, no constructor parameter-property shorthand (tsconfig `erasableSyntaxOnly`). Iterating parsed JSON in C++: bind to a named var first (range-for over a temporary dangles).
|
|
|
|
|
+- `api/openapi.json` and `api/llms.txt` MUST stay in sync with routes — they are shipped and served at `/docs`.
|
|
|
|
|
+- Version for both debs: **0.2.0**. This is the version published to the apt repo; nothing is published before Task 13.
|
|
|
|
|
+- **Integration tests require a running DB 2.11.1.** `ApiFixture::SetUp` calls `GTEST_SKIP()` when the DB is unreachable, so a green run can mean "nothing ran". Every `ctest` run in this plan must be checked for skips, not just failures.
|
|
|
|
|
+- Commits use conventional prefixes (`feat`/`fix`/`build`/`docs`/`test`).
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: Development environment on DB 2.11.1
|
|
|
|
|
+
|
|
|
|
|
+Without this, every integration test in this plan silently skips and the plan appears to pass while testing nothing.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: none (environment only)
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Produces: a `smartbotic-database` 2.11.1 listening on `localhost:9004` for all later tasks.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Check what the dev box currently runs**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+dpkg-query -W -f='${Package} ${Version}\n' 'smartbotic-*' 'libsmartbotic-*' 2>/dev/null
|
|
|
|
|
+ss -ltnp 2>/dev/null | grep 9004 || echo "nothing on 9004"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Install DB + client-dev 2.11.1 locally**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+sudo apt-get update
|
|
|
|
|
+sudo apt-get install -y smartbotic-database libsmartbotic-db-client libsmartbotic-db-client-dev
|
|
|
|
|
+dpkg-query -W -f='${Package} ${Version}\n' smartbotic-database libsmartbotic-db-client-dev
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: both report `2.11.1-1`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Confirm the DB is reachable**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+sudo systemctl restart smartbotic-database
|
|
|
|
|
+sleep 3
|
|
|
|
|
+systemctl is-active smartbotic-database
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: `active`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Rebuild the project and confirm tests RUN (not skip)**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cmake -B build -G Ninja -DBUILD_TESTS=ON -DBUILD_WEBUI=OFF -S . && cmake --build build -j"$(nproc)"
|
|
|
|
|
+ctest --test-dir build --output-on-failure 2>&1 | tail -5
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: `101 tests passed`, and **zero** "Skipped". If you see skips, the DB is not reachable — stop and fix before continuing.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+Nothing to commit (environment only). Proceed to Task 2.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: `ErrCode::Conflict` (409) and structured error details
|
|
|
|
|
+
|
|
|
|
|
+Both unique-index violations and relation-blocked deletes need 409 plus a structured payload. Doing this first means later tasks just use it.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/errors.hpp`
|
|
|
|
|
+- Modify: `src/errors.cpp`
|
|
|
|
|
+- Test: `tests/test_errors.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Produces: `ErrCode::Conflict` -> HTTP 409; `errorBody(code, message, details)` where `details` is an optional `nlohmann::json` object merged into the `error` object; `ApiError` gains a `details` member that the global exception handler emits.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+Append to `tests/test_errors.cpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST(Errors, ConflictMapsTo409) {
|
|
|
|
|
+ EXPECT_EQ(svapi::httpStatus(svapi::ErrCode::Conflict), 409);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(Errors, ErrorBodyCarriesDetails) {
|
|
|
|
|
+ nlohmann::json det = {{"duplicate_examples", nlohmann::json::array({"a", "b"})}};
|
|
|
|
|
+ auto body = svapi::errorBody("duplicate_values", "field holds duplicates", det);
|
|
|
|
|
+ EXPECT_EQ(body["error"]["code"], "duplicate_values");
|
|
|
|
|
+ EXPECT_EQ(body["error"]["message"], "field holds duplicates");
|
|
|
|
|
+ ASSERT_TRUE(body["error"].contains("details"));
|
|
|
|
|
+ EXPECT_EQ(body["error"]["details"]["duplicate_examples"].size(), 2u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(Errors, ErrorBodyOmitsEmptyDetails) {
|
|
|
|
|
+ auto body = svapi::errorBody("validation", "bad");
|
|
|
|
|
+ EXPECT_FALSE(body["error"].contains("details"));
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_errors`
|
|
|
|
|
+Expected: FAIL — `Conflict` is not a member of `ErrCode`, and `errorBody` takes 2 args.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+In `src/errors.hpp`, add `Conflict` to the enum and extend the signatures:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+enum class ErrCode { BadRequest, Unauthorized, Forbidden, NotFound, Conflict,
|
|
|
|
|
+ Unprocessable, TooManyRequests, Unavailable, Internal };
|
|
|
|
|
+
|
|
|
|
|
+int httpStatus(ErrCode code);
|
|
|
|
|
+
|
|
|
|
|
+/// `details` is merged into the error object as `error.details` when non-empty.
|
|
|
|
|
+/// Used for machine-readable payloads (colliding ids, relation impacts) that
|
|
|
|
|
+/// would otherwise be flattened into the message string.
|
|
|
|
|
+nlohmann::json errorBody(const std::string& code, const std::string& message,
|
|
|
|
|
+ const nlohmann::json& details = nlohmann::json::object());
|
|
|
|
|
+
|
|
|
|
|
+struct ApiError : std::runtime_error {
|
|
|
|
|
+ ErrCode code;
|
|
|
|
|
+ std::string slug;
|
|
|
|
|
+ nlohmann::json details; // optional structured payload; empty object = omitted
|
|
|
|
|
+ ApiError(ErrCode c, std::string slug_, const std::string& msg,
|
|
|
|
|
+ nlohmann::json det = nlohmann::json::object())
|
|
|
|
|
+ : std::runtime_error(msg), code(c), slug(std::move(slug_)), details(std::move(det)) {}
|
|
|
|
|
+};
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+In `src/errors.cpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+int httpStatus(ErrCode code) {
|
|
|
|
|
+ switch (code) {
|
|
|
|
|
+ case ErrCode::BadRequest: return 400;
|
|
|
|
|
+ case ErrCode::Unauthorized: return 401;
|
|
|
|
|
+ case ErrCode::Forbidden: return 403;
|
|
|
|
|
+ case ErrCode::NotFound: return 404;
|
|
|
|
|
+ case ErrCode::Conflict: return 409;
|
|
|
|
|
+ case ErrCode::Unprocessable: return 422;
|
|
|
|
|
+ case ErrCode::TooManyRequests: return 429;
|
|
|
|
|
+ case ErrCode::Unavailable: return 503;
|
|
|
|
|
+ case ErrCode::Internal: return 500;
|
|
|
|
|
+ }
|
|
|
|
|
+ return 500;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+nlohmann::json errorBody(const std::string& code, const std::string& message,
|
|
|
|
|
+ const nlohmann::json& details) {
|
|
|
|
|
+ nlohmann::json err{{"code", code}, {"message", message}};
|
|
|
|
|
+ if (!details.is_null() && !details.empty()) err["details"] = details;
|
|
|
|
|
+ return nlohmann::json{{"error", err}};
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Wire the exception handler to emit details**
|
|
|
|
|
+
|
|
|
|
|
+Find the global exception handler in `src/server.cpp` (it calls `errorBody(e.slug, e.what())`) and pass the details through:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+sendJson(res, httpStatus(e.code), errorBody(e.slug, e.what(), e.details));
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_errors && ctest --test-dir build --output-on-failure 2>&1 | tail -3`
|
|
|
|
|
+Expected: all PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/errors.hpp src/errors.cpp src/server.cpp tests/test_errors.cpp
|
|
|
|
|
+git commit -m "feat(errors): add 409 Conflict and structured error details"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: Index create / list / drop
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `src/handlers/indexes.cpp`
|
|
|
|
|
+- Modify: `src/CMakeLists.txt` (add `handlers/indexes.cpp` after `handlers/collections.cpp`)
|
|
|
|
|
+- Modify: `src/server.hpp` (declare `void registerIndexRoutes(ApiServer&);`)
|
|
|
|
|
+- Modify: `src/server.cpp` (call `registerIndexRoutes(*this);` alongside the other register calls)
|
|
|
|
|
+- Test: `tests/test_api_integration.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `ErrCode::Conflict` (Task 2); `qualify(project, collection)`; `requireProjectManage`, `requireProjectAccess`.
|
|
|
|
|
+- Produces: routes `POST|GET /api/v1/projects/{p}/collections/{c}/indexes` and `DELETE .../indexes/{field}`. POST response `{field, unique, rows_indexed, already_existed}`; GET response `{indexes:[{field, distinct_values, entries, unique}]}`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+Append to `tests/test_api_integration.cpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, IndexCreateListDrop) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","idxc"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ std::string idx = base + "/idxc/indexes";
|
|
|
|
|
+
|
|
|
|
|
+ auto made = c.Post(idx.c_str(), nlohmann::json{{"field","status"}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
|
|
|
|
|
+ auto madeJson = nlohmann::json::parse(made->body);
|
|
|
|
|
+ EXPECT_EQ(madeJson["field"], "status");
|
|
|
|
|
+ EXPECT_FALSE(madeJson["unique"].get<bool>());
|
|
|
|
|
+
|
|
|
|
|
+ auto list = c.Get(idx.c_str());
|
|
|
|
|
+ ASSERT_EQ(list->status, 200);
|
|
|
|
|
+ auto listJson = nlohmann::json::parse(list->body);
|
|
|
|
|
+ bool found = false;
|
|
|
|
|
+ for (auto& e : listJson["indexes"]) if (e["field"] == "status") found = true;
|
|
|
|
|
+ EXPECT_TRUE(found);
|
|
|
|
|
+
|
|
|
|
|
+ EXPECT_EQ(c.Delete((idx + "/status").c_str())->status, 200);
|
|
|
|
|
+ auto after = c.Get(idx.c_str());
|
|
|
|
|
+ auto afterJson = nlohmann::json::parse(after->body);
|
|
|
|
|
+ for (auto& e : afterJson["indexes"]) EXPECT_NE(e["field"], "status");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, IndexCreateRequiresField) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","idxv"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ auto bad = c.Post((base + "/idxv/indexes").c_str(), nlohmann::json{}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Index*'`
|
|
|
|
|
+Expected: FAIL with 404 on the index routes (not registered).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+Create `src/handlers/indexes.cpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+
|
|
|
|
|
+void registerIndexRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+
|
|
|
|
|
+ // Declare an index. Backfills existing rows, so it is usable immediately.
|
|
|
|
|
+ // Idempotent: re-declaring an existing index reports already_existed.
|
|
|
|
|
+ svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes)",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1], coll = req.matches[2];
|
|
|
|
|
+ requireProjectManage(k, project);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::string field = body.value("field", "");
|
|
|
|
|
+ if (field.empty()) throw ApiError(ErrCode::Unprocessable, "validation", "field is required");
|
|
|
|
|
+
|
|
|
|
|
+ const std::string qc = qualify(project, coll);
|
|
|
|
|
+ if (!d->db.client().getCollectionInfo(qc))
|
|
|
|
|
+ throw ApiError(ErrCode::NotFound, "not_found", "collection not found");
|
|
|
|
|
+
|
|
|
|
|
+ uint64_t rows = 0;
|
|
|
|
|
+ if (!d->db.client().createIndex(qc, field, rows))
|
|
|
|
|
+ throw ApiError(ErrCode::Unavailable, "db_error", "failed to create index");
|
|
|
|
|
+ sendJson(res, 201, {{"field", field}, {"unique", false},
|
|
|
|
|
+ {"rows_indexed", rows}, {"already_existed", false}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes)",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1], coll = req.matches[2];
|
|
|
|
|
+ requireProjectAccess(k, project);
|
|
|
|
|
+
|
|
|
|
|
+ const std::string qc = qualify(project, coll);
|
|
|
|
|
+ if (!d->db.client().getCollectionInfo(qc))
|
|
|
|
|
+ throw ApiError(ErrCode::NotFound, "not_found", "collection not found");
|
|
|
|
|
+
|
|
|
|
|
+ std::vector<std::string> uniqueFields;
|
|
|
|
|
+ auto defs = d->db.client().listIndexes(qc, uniqueFields);
|
|
|
|
|
+ nlohmann::json out = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& def : defs) {
|
|
|
|
|
+ bool uniq = false;
|
|
|
|
|
+ for (const auto& u : uniqueFields) if (u == def.field) uniq = true;
|
|
|
|
|
+ out.push_back({{"field", def.field},
|
|
|
|
|
+ {"distinct_values", def.distinctValues},
|
|
|
|
|
+ {"entries", def.entries},
|
|
|
|
|
+ {"unique", uniq}});
|
|
|
|
|
+ }
|
|
|
|
|
+ sendJson(res, 200, {{"indexes", out}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes/([^/]+))",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1], coll = req.matches[2], field = req.matches[3];
|
|
|
|
|
+ requireProjectManage(k, project);
|
|
|
|
|
+ if (!d->db.client().dropIndex(qualify(project, coll), field))
|
|
|
|
|
+ throw ApiError(ErrCode::NotFound, "not_found", "index not found");
|
|
|
|
|
+ sendJson(res, 200, {{"dropped", field}});
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Note: confirm the second `listIndexes` overload's out-parameter type against `/usr/include/smartbotic/database/client.hpp:730` before building; if it differs, adapt this call and keep the response shape identical.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Wire it up**
|
|
|
|
|
+
|
|
|
|
|
+In `src/server.hpp`, beside the other declarations:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+void registerIndexRoutes(ApiServer&);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+In `src/server.cpp`, beside the other register calls:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+registerIndexRoutes(*this);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+In `src/CMakeLists.txt`, after `handlers/collections.cpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cmake
|
|
|
|
|
+ handlers/indexes.cpp
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Index*'`
|
|
|
|
|
+Expected: PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/handlers/indexes.cpp src/server.hpp src/server.cpp src/CMakeLists.txt tests/test_api_integration.cpp
|
|
|
|
|
+git commit -m "feat(indexes): declare, list and drop secondary indexes"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: Unique indexes with 409 duplicate reporting
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/handlers/indexes.cpp`
|
|
|
|
|
+- Test: `tests/test_api_integration.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `ErrCode::Conflict` + `ApiError` details (Task 2); the POST route from Task 3.
|
|
|
|
|
+- Produces: `POST .../indexes {field, unique:true}`; on duplicates, HTTP 409 slug `duplicate_values` with `error.details.duplicate_examples` = array of colliding document ids.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, UniqueIndexRejectsExistingDuplicates) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","uq"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ std::string docs = base + "/uq/documents";
|
|
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","A1"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","A1"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto conflict = c.Post((base + "/uq/indexes").c_str(),
|
|
|
|
|
+ nlohmann::json{{"field","sku"},{"unique",true}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(conflict); EXPECT_EQ(conflict->status, 409);
|
|
|
|
|
+ auto body = nlohmann::json::parse(conflict->body);
|
|
|
|
|
+ EXPECT_EQ(body["error"]["code"], "duplicate_values");
|
|
|
|
|
+ ASSERT_TRUE(body["error"]["details"].contains("duplicate_examples"));
|
|
|
|
|
+ EXPECT_GE(body["error"]["details"]["duplicate_examples"].size(), 1u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, UniqueIndexOnCleanFieldSucceeds) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","uq2"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ std::string docs = base + "/uq2/documents";
|
|
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","B1"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","B2"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto made = c.Post((base + "/uq2/indexes").c_str(),
|
|
|
|
|
+ nlohmann::json{{"field","sku"},{"unique",true}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
|
|
|
|
|
+ EXPECT_TRUE(nlohmann::json::parse(made->body)["unique"].get<bool>());
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Unique*'`
|
|
|
|
|
+Expected: FAIL — `unique` is ignored, so both return 201.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+Replace the body of the POST handler in `src/handlers/indexes.cpp` (after the `field` validation and collection check) with:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ const bool unique = body.value("unique", false);
|
|
|
|
|
+ if (unique) {
|
|
|
|
|
+ auto r = d->db.client().createUniqueIndex(qc, field);
|
|
|
|
|
+ if (!r.success) {
|
|
|
|
|
+ if (!r.duplicateExamples.empty()) {
|
|
|
|
|
+ nlohmann::json ex = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& id : r.duplicateExamples) ex.push_back(id);
|
|
|
|
|
+ throw ApiError(ErrCode::Conflict, "duplicate_values",
|
|
|
|
|
+ "field '" + field + "' already holds duplicate values",
|
|
|
|
|
+ {{"duplicate_examples", ex}});
|
|
|
|
|
+ }
|
|
|
|
|
+ throw ApiError(ErrCode::Unavailable, "db_error",
|
|
|
|
|
+ r.error.empty() ? "failed to create unique index" : r.error);
|
|
|
|
|
+ }
|
|
|
|
|
+ sendJson(res, 201, {{"field", field}, {"unique", true},
|
|
|
|
|
+ {"rows_indexed", r.rowsIndexed},
|
|
|
|
|
+ {"already_existed", r.alreadyExisted}});
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ uint64_t rows = 0;
|
|
|
|
|
+ if (!d->db.client().createIndex(qc, field, rows))
|
|
|
|
|
+ throw ApiError(ErrCode::Unavailable, "db_error", "failed to create index");
|
|
|
|
|
+ sendJson(res, 201, {{"field", field}, {"unique", false},
|
|
|
|
|
+ {"rows_indexed", rows}, {"already_existed", false}});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Unique*:*Index*'`
|
|
|
|
|
+Expected: PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/handlers/indexes.cpp tests/test_api_integration.cpp
|
|
|
|
|
+git commit -m "feat(indexes): unique indexes with 409 duplicate reporting"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: Facet values endpoint
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/handlers/indexes.cpp`
|
|
|
|
|
+- Test: `tests/test_api_integration.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `requireCapability(*d, k, req, project, collection, KeyOp::Read)`.
|
|
|
|
|
+- Produces: `GET .../indexes/{field}/values?limit=100&order=asc|desc` -> `{values:[{value, count}]}`. Empty array for an unindexed or array-valued field (documented, not an error).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, IndexValuesReturnsFacetCounts) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","facets"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ std::string docs = base + "/facets/documents";
|
|
|
|
|
+ for (const char* st : {"open", "open", "closed"})
|
|
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"status", st}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ ASSERT_EQ(c.Post((base + "/facets/indexes").c_str(),
|
|
|
|
|
+ nlohmann::json{{"field","status"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto r = c.Get((base + "/facets/indexes/status/values").c_str());
|
|
|
|
|
+ ASSERT_TRUE(r); EXPECT_EQ(r->status, 200);
|
|
|
|
|
+ auto body = nlohmann::json::parse(r->body);
|
|
|
|
|
+ uint64_t open = 0, closed = 0;
|
|
|
|
|
+ for (auto& e : body["values"]) {
|
|
|
|
|
+ if (e["value"] == "open") open = e["count"].get<uint64_t>();
|
|
|
|
|
+ if (e["value"] == "closed") closed = e["count"].get<uint64_t>();
|
|
|
|
|
+ }
|
|
|
|
|
+ EXPECT_EQ(open, 2u);
|
|
|
|
|
+ EXPECT_EQ(closed, 1u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, IndexValuesEmptyForUnindexedField) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","nofacet"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ auto r = c.Get((base + "/nofacet/indexes/whatever/values").c_str());
|
|
|
|
|
+ ASSERT_TRUE(r); EXPECT_EQ(r->status, 200);
|
|
|
|
|
+ EXPECT_TRUE(nlohmann::json::parse(r->body)["values"].empty());
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*IndexValues*'`
|
|
|
|
|
+Expected: FAIL with 404 — the route does not exist.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+Add to `registerIndexRoutes` in `src/handlers/indexes.cpp`. Register this BEFORE the `DELETE .../indexes/{field}` route is irrelevant (different verb), but it MUST be declared before any broader GET pattern that could shadow it:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ // Distinct values of an indexed field, with counts. Index-dependent by
|
|
|
|
|
+ // design: returns an empty list for an unindexed field and for array-valued
|
|
|
|
|
+ // fields, whose index keys are elements rather than values.
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes/([^/]+)/values)",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1], coll = req.matches[2], field = req.matches[3];
|
|
|
|
|
+ requireCapability(*d, k, req, project, coll, KeyOp::Read);
|
|
|
|
|
+
|
|
|
|
|
+ uint32_t limit = 100;
|
|
|
|
|
+ if (req.has_param("limit")) {
|
|
|
|
|
+ limit = (uint32_t)std::strtoul(req.get_param_value("limit").c_str(), nullptr, 10);
|
|
|
|
|
+ if (limit == 0 || limit > 1000)
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "validation", "limit must be 1..1000");
|
|
|
|
|
+ }
|
|
|
|
|
+ const bool ascending = !(req.has_param("order") && req.get_param_value("order") == "desc");
|
|
|
|
|
+
|
|
|
|
|
+ auto vals = d->db.client().indexValues(qualify(project, coll), field, limit, ascending);
|
|
|
|
|
+ nlohmann::json out = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& v : vals) out.push_back({{"value", v.value}, {"count", v.count}});
|
|
|
|
|
+ sendJson(res, 200, {{"values", out}});
|
|
|
|
|
+ });
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add `#include <cstdlib>` at the top of the file for `strtoul`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*IndexValues*:*Index*:*Unique*'`
|
|
|
|
|
+Expected: PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/handlers/indexes.cpp tests/test_api_integration.cpp
|
|
|
|
|
+git commit -m "feat(indexes): facet values endpoint with counts"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 6: Document TTL on insert and patch
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/handlers/documents.cpp`
|
|
|
|
|
+- Test: `tests/test_api_integration.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Produces: `ttl_seconds` query parameter on `POST .../documents` and `PATCH .../documents/{id}`. On PATCH, **absence** leaves the expiry untouched (3-arg `patch`); **presence** sets it (`patchWithTtl`), and `ttl_seconds=0` CLEARS it.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, DocumentTtlAcceptedOnInsertAndPatch) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlc"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ std::string docs = base + "/ttlc/documents";
|
|
|
|
|
+
|
|
|
|
|
+ auto made = c.Post((docs + "?ttl_seconds=3600").c_str(),
|
|
|
|
|
+ nlohmann::json{{"v",1}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
|
|
|
|
|
+ std::string id = nlohmann::json::parse(made->body)["id"];
|
|
|
|
|
+
|
|
|
|
|
+ // Document is readable while the TTL is in the future.
|
|
|
|
|
+ EXPECT_EQ(c.Get((docs + "/" + id).c_str())->status, 200);
|
|
|
|
|
+
|
|
|
|
|
+ // ttl_seconds=0 on PATCH clears the expiry (does not delete the document).
|
|
|
|
|
+ auto cleared = c.Patch((docs + "/" + id + "?ttl_seconds=0").c_str(),
|
|
|
|
|
+ nlohmann::json{{"v",2}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(cleared); EXPECT_EQ(cleared->status, 200);
|
|
|
|
|
+ auto got = c.Get((docs + "/" + id).c_str());
|
|
|
|
|
+ ASSERT_EQ(got->status, 200);
|
|
|
|
|
+ EXPECT_EQ(nlohmann::json::parse(got->body)["v"], 2);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, DocumentTtlRejectsNonNumeric) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlbad"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ auto bad = c.Post((base + "/ttlbad/documents?ttl_seconds=soon").c_str(),
|
|
|
|
|
+ nlohmann::json{{"v",1}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*DocumentTtl*'`
|
|
|
|
|
+Expected: FAIL — `ttl_seconds=soon` is ignored, so the bad case returns 201.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+At the top of `src/handlers/documents.cpp`, add a shared parser:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+// Parses ?ttl_seconds=. Returns nullopt when absent — the caller must
|
|
|
|
|
+// distinguish "not supplied" (leave expiry alone) from 0 (clear the expiry).
|
|
|
|
|
+static std::optional<uint32_t> ttlParam(const httplib::Request& req) {
|
|
|
|
|
+ if (!req.has_param("ttl_seconds")) return std::nullopt;
|
|
|
|
|
+ const std::string raw = req.get_param_value("ttl_seconds");
|
|
|
|
|
+ if (raw.empty() || raw.find_first_not_of("0123456789") != std::string::npos)
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "validation", "ttl_seconds must be a non-negative integer");
|
|
|
|
|
+ return (uint32_t)std::strtoul(raw.c_str(), nullptr, 10);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add `#include <optional>` and `#include <cstdlib>` if not already present.
|
|
|
|
|
+
|
|
|
|
|
+In the POST (insert) handler, pass it through to `insert`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ const uint32_t ttl = ttlParam(req).value_or(0);
|
|
|
|
|
+ std::string newId = d->db.client().insert(qualify(project, coll), doc, id, ttl, actor);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+In the PATCH handler, branch on presence:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ const auto ttl = ttlParam(req);
|
|
|
|
|
+ uint64_t version = ttl ? d->db.client().patchWithTtl(qualify(project, coll), id, doc, *ttl, actor)
|
|
|
|
|
+ : d->db.client().patch(qualify(project, coll), id, doc, actor);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Keep the existing handling of the returned version/failure exactly as it is; only the call selection changes.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*DocumentTtl*'`
|
|
|
|
|
+Expected: PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/handlers/documents.cpp tests/test_api_integration.cpp
|
|
|
|
|
+git commit -m "feat(documents): ttl_seconds on insert and patch"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 7: Relation create / list / get / drop
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `src/handlers/relations.cpp`
|
|
|
|
|
+- Modify: `src/CMakeLists.txt`, `src/server.hpp`, `src/server.cpp`
|
|
|
|
|
+- Test: `tests/test_api_integration.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `qualify(project, name)` — applied to the relation NAME as well as child/parent collections (verified: the server rejects a bare name whose collections are in another project).
|
|
|
|
|
+- Produces: `POST|GET /api/v1/projects/{p}/relations`, `GET|DELETE /api/v1/projects/{p}/relations/{name}`. Relation objects are returned with **unqualified** names and collections, so the API never leaks the `project:` prefix.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, RelationCrud) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","orders"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","customers"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ std::string rel = "/api/v1/projects/" + project_ + "/relations";
|
|
|
|
|
+ auto made = c.Post(rel.c_str(), nlohmann::json{
|
|
|
|
|
+ {"name","order_customer"}, {"child","orders"}, {"child_field","customer_id"},
|
|
|
|
|
+ {"parent","customers"}, {"on_delete","restrict"}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto list = c.Get(rel.c_str());
|
|
|
|
|
+ ASSERT_EQ(list->status, 200);
|
|
|
|
|
+ auto listJson = nlohmann::json::parse(list->body);
|
|
|
|
|
+ bool found = false;
|
|
|
|
|
+ for (auto& e : listJson["relations"])
|
|
|
|
|
+ if (e["name"] == "order_customer") { found = true; EXPECT_EQ(e["child"], "orders"); }
|
|
|
|
|
+ EXPECT_TRUE(found);
|
|
|
|
|
+
|
|
|
|
|
+ auto one = c.Get((rel + "/order_customer").c_str());
|
|
|
|
|
+ ASSERT_EQ(one->status, 200);
|
|
|
|
|
+ EXPECT_EQ(nlohmann::json::parse(one->body)["parent"], "customers");
|
|
|
|
|
+
|
|
|
|
|
+ EXPECT_EQ(c.Delete((rel + "/order_customer").c_str())->status, 200);
|
|
|
|
|
+ EXPECT_EQ(c.Get((rel + "/order_customer").c_str())->status, 404);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, RelationRejectsBadOnDelete) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string rel = "/api/v1/projects/" + project_ + "/relations";
|
|
|
|
|
+ auto bad = c.Post(rel.c_str(), nlohmann::json{
|
|
|
|
|
+ {"name","r"}, {"child","a"}, {"child_field","b"},
|
|
|
|
|
+ {"parent","c"}, {"on_delete","explode"}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Relation*'`
|
|
|
|
|
+Expected: FAIL with 404 — routes not registered.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+Create `src/handlers/relations.cpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+namespace {
|
|
|
|
|
+
|
|
|
|
|
+// Strip a leading "project:" so the API never leaks qualified names outward.
|
|
|
|
|
+std::string unqualify(const std::string& project, const std::string& name) {
|
|
|
|
|
+ const std::string pfx = project + ":";
|
|
|
|
|
+ return name.rfind(pfx, 0) == 0 ? name.substr(pfx.size()) : name;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+nlohmann::json relJson(const std::string& project,
|
|
|
|
|
+ const smartbotic::database::Client::RelationDefinition& r) {
|
|
|
|
|
+ return {{"name", unqualify(project, r.name)},
|
|
|
|
|
+ {"child", unqualify(project, r.child)},
|
|
|
|
|
+ {"child_field", r.childField},
|
|
|
|
|
+ {"parent", unqualify(project, r.parent)},
|
|
|
|
|
+ {"on_delete", r.onDelete},
|
|
|
|
|
+ {"validate_on_write", r.validateOnWrite},
|
|
|
|
|
+ {"created_at", r.createdAt},
|
|
|
|
|
+ {"updated_at", r.updatedAt}};
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace
|
|
|
|
|
+
|
|
|
|
|
+void registerRelationRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+
|
|
|
|
|
+ svr.Post(R"(/api/v1/projects/([^/]+)/relations)",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
|
|
|
|
|
+ requireAdmin(k); requireProjectAccess(k, project);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::string name = body.value("name", ""), child = body.value("child", ""),
|
|
|
|
|
+ field = body.value("child_field", ""), parent = body.value("parent", ""),
|
|
|
|
|
+ onDelete = body.value("on_delete", "restrict");
|
|
|
|
|
+ if (name.empty() || child.empty() || field.empty() || parent.empty())
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "validation",
|
|
|
|
|
+ "name, child, child_field and parent are required");
|
|
|
|
|
+ if (name.rfind('_', 0) == 0 || name.rfind("vectorapi_", 0) == 0)
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "validation",
|
|
|
|
|
+ "name may not start with '_' or the reserved 'vectorapi_' prefix");
|
|
|
|
|
+ if (onDelete != "restrict" && onDelete != "cascade" &&
|
|
|
|
|
+ onDelete != "set_null" && onDelete != "no_action")
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "validation",
|
|
|
|
|
+ "on_delete must be restrict, cascade, set_null or no_action");
|
|
|
|
|
+ const bool validateOnWrite = body.value("validate_on_write", false);
|
|
|
|
|
+
|
|
|
|
|
+ // The relation NAME is project-qualified exactly like the collections.
|
|
|
|
|
+ if (!d->db.client().createRelation(qualify(project, name), qualify(project, child), field,
|
|
|
|
|
+ qualify(project, parent), onDelete, validateOnWrite))
|
|
|
|
|
+ throw ApiError(ErrCode::Unavailable, "db_error", "failed to create relation");
|
|
|
|
|
+ sendJson(res, 201, {{"name", name}, {"child", child}, {"child_field", field},
|
|
|
|
|
+ {"parent", parent}, {"on_delete", onDelete},
|
|
|
|
|
+ {"validate_on_write", validateOnWrite}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/relations)",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
|
|
|
|
|
+ requireAdmin(k); requireProjectAccess(k, project);
|
|
|
|
|
+ auto defs = d->db.client().listRelations(project);
|
|
|
|
|
+ nlohmann::json out = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& r : defs) out.push_back(relJson(project, r));
|
|
|
|
|
+ sendJson(res, 200, {{"relations", out}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/relations/([^/]+))",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1], name = req.matches[2];
|
|
|
|
|
+ requireAdmin(k); requireProjectAccess(k, project);
|
|
|
|
|
+ auto info = d->db.client().getRelationInfo(qualify(project, name));
|
|
|
|
|
+ if (!info) throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
|
|
|
|
|
+ sendJson(res, 200, relJson(project, *info));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Delete(R"(/api/v1/projects/([^/]+)/relations/([^/]+))",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1], name = req.matches[2];
|
|
|
|
|
+ requireAdmin(k); requireProjectAccess(k, project);
|
|
|
|
|
+ if (!d->db.client().dropRelation(qualify(project, name)))
|
|
|
|
|
+ throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
|
|
|
|
|
+ sendJson(res, 200, {{"dropped", name}});
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Check the exact `createRelation` overload signature at `client.hpp:835` and `:851` — one takes `validateOnWrite`, one does not. Use the matching overload.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Wire it up**
|
|
|
|
|
+
|
|
|
|
|
+`src/server.hpp`: `void registerRelationRoutes(ApiServer&);`
|
|
|
|
|
+`src/server.cpp`: `registerRelationRoutes(*this);`
|
|
|
|
|
+`src/CMakeLists.txt`: ` handlers/relations.cpp`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Relation*'`
|
|
|
|
|
+Expected: PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/handlers/relations.cpp src/server.hpp src/server.cpp src/CMakeLists.txt tests/test_api_integration.cpp
|
|
|
|
|
+git commit -m "feat(relations): create, list, get and drop relations"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 8: Delete-impact, enforcement toggle, and 409 on blocked delete
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/handlers/relations.cpp` (enforcement toggle + delete-impact)
|
|
|
|
|
+- Modify: `src/handlers/documents.cpp` (409 on blocked delete)
|
|
|
|
|
+- Test: `tests/test_api_integration.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `describeDelete` -> `{success, error, wouldBeBlocked, impacts[]}` where each impact has `{relation, childCollection, childField, onDelete, childCount, sampleChildIds, blocks}`.
|
|
|
|
|
+- Produces: `GET .../documents/{id}/delete-impact`; `PUT .../collections/{c}/relations-enforced`; document DELETE returns 409 slug `relation_restricted` with `error.details.impacts`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, DeleteBlockedByRelationReturns409WithImpacts) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","inv"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","cust"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ std::string rel = "/api/v1/projects/" + project_ + "/relations";
|
|
|
|
|
+ ASSERT_EQ(c.Post(rel.c_str(), nlohmann::json{
|
|
|
|
|
+ {"name","inv_cust"}, {"child","inv"}, {"child_field","cust_id"},
|
|
|
|
|
+ {"parent","cust"}, {"on_delete","restrict"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto p = c.Post((base + "/cust/documents").c_str(),
|
|
|
|
|
+ nlohmann::json{{"n","acme"}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_EQ(p->status, 201);
|
|
|
|
|
+ std::string pid = nlohmann::json::parse(p->body)["id"];
|
|
|
|
|
+ ASSERT_EQ(c.Post((base + "/inv/documents").c_str(),
|
|
|
|
|
+ nlohmann::json{{"cust_id", pid}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto impact = c.Get((base + "/cust/documents/" + pid + "/delete-impact").c_str());
|
|
|
|
|
+ ASSERT_TRUE(impact); EXPECT_EQ(impact->status, 200);
|
|
|
|
|
+ auto impactJson = nlohmann::json::parse(impact->body);
|
|
|
|
|
+ EXPECT_TRUE(impactJson["would_be_blocked"].get<bool>());
|
|
|
|
|
+ EXPECT_GE(impactJson["impacts"].size(), 1u);
|
|
|
|
|
+
|
|
|
|
|
+ auto del = c.Delete((base + "/cust/documents/" + pid).c_str());
|
|
|
|
|
+ ASSERT_TRUE(del); EXPECT_EQ(del->status, 409);
|
|
|
|
|
+ auto delJson = nlohmann::json::parse(del->body);
|
|
|
|
|
+ EXPECT_EQ(delJson["error"]["code"], "relation_restricted");
|
|
|
|
|
+ EXPECT_GE(delJson["error"]["details"]["impacts"].size(), 1u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, RelationsEnforcedTogglePermitsDelete) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","inv2"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","cust2"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ std::string rel = "/api/v1/projects/" + project_ + "/relations";
|
|
|
|
|
+ ASSERT_EQ(c.Post(rel.c_str(), nlohmann::json{
|
|
|
|
|
+ {"name","inv2_cust2"}, {"child","inv2"}, {"child_field","cust_id"},
|
|
|
|
|
+ {"parent","cust2"}, {"on_delete","restrict"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto p = c.Post((base + "/cust2/documents").c_str(),
|
|
|
|
|
+ nlohmann::json{{"n","x"}}.dump(), "application/json");
|
|
|
|
|
+ std::string pid = nlohmann::json::parse(p->body)["id"];
|
|
|
|
|
+ ASSERT_EQ(c.Post((base + "/inv2/documents").c_str(),
|
|
|
|
|
+ nlohmann::json{{"cust_id", pid}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto off = c.Put((base + "/inv2/relations-enforced").c_str(),
|
|
|
|
|
+ nlohmann::json{{"enforced", false}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
|
|
|
|
|
+ EXPECT_EQ(c.Delete((base + "/cust2/documents/" + pid).c_str())->status, 200);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*DeleteBlocked*:*RelationsEnforced*'`
|
|
|
|
|
+Expected: FAIL — delete-impact route missing (404) and delete returns 503/500 rather than 409.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Implement delete-impact and the enforcement toggle**
|
|
|
|
|
+
|
|
|
|
|
+Add to `registerRelationRoutes` in `src/handlers/relations.cpp` (and add a shared helper the documents handler will reuse):
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+// Shared with handlers/documents.cpp: renders describeDelete impacts as JSON.
|
|
|
|
|
+nlohmann::json impactsJson(const std::string& project,
|
|
|
|
|
+ const smartbotic::database::Client::DescribeDeleteResult& r) {
|
|
|
|
|
+ nlohmann::json arr = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& i : r.impacts) {
|
|
|
|
|
+ nlohmann::json ids = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& s : i.sampleChildIds) ids.push_back(s);
|
|
|
|
|
+ arr.push_back({{"relation", unqualify(project, i.relation)},
|
|
|
|
|
+ {"child_collection", unqualify(project, i.childCollection)},
|
|
|
|
|
+ {"child_field", i.childField},
|
|
|
|
|
+ {"on_delete", i.onDelete},
|
|
|
|
|
+ {"child_count", i.childCount},
|
|
|
|
|
+ {"sample_child_ids", ids},
|
|
|
|
|
+ {"blocks", i.blocks}});
|
|
|
|
|
+ }
|
|
|
|
|
+ return arr;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Move `unqualify` and `impactsJson` out of the anonymous namespace into `namespace svapi` and declare both in `src/server.hpp` so `documents.cpp` can call them:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+std::string unqualify(const std::string& project, const std::string& name);
|
|
|
|
|
+nlohmann::json impactsJson(const std::string& project,
|
|
|
|
|
+ const smartbotic::database::Client::DescribeDeleteResult& r);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Routes:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+)/delete-impact)",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1], coll = req.matches[2], id = req.matches[3];
|
|
|
|
|
+ // Read-only: changes nothing, but child counts and sample ids are facts
|
|
|
|
|
+ // about data, so it follows the collection's read grant.
|
|
|
|
|
+ requireCapability(*d, k, req, project, coll, KeyOp::Read);
|
|
|
|
|
+ auto r = d->db.client().describeDelete(qualify(project, coll), id);
|
|
|
|
|
+ if (!r.success) throw ApiError(ErrCode::NotFound, "not_found",
|
|
|
|
|
+ r.error.empty() ? "document not found" : r.error);
|
|
|
|
|
+ sendJson(res, 200, {{"would_be_blocked", r.wouldBeBlocked},
|
|
|
|
|
+ {"impacts", impactsJson(project, r)}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Put(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/relations-enforced)",
|
|
|
|
|
+ [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1], coll = req.matches[2];
|
|
|
|
|
+ requireAdmin(k); requireProjectAccess(k, project);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ if (!body.contains("enforced") || !body["enforced"].is_boolean())
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "validation", "enforced (boolean) is required");
|
|
|
|
|
+ const bool enforced = body["enforced"].get<bool>();
|
|
|
|
|
+ if (!d->db.client().setRelationsEnforced(qualify(project, coll), enforced))
|
|
|
|
|
+ throw ApiError(ErrCode::Unavailable, "db_error", "failed to set relations_enforced");
|
|
|
|
|
+ sendJson(res, 200, {{"collection", coll}, {"enforced", enforced}});
|
|
|
|
|
+ });
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Return 409 from the document delete**
|
|
|
|
|
+
|
|
|
|
|
+In `src/handlers/documents.cpp`, replace the 2-argument `remove` with the 3-argument overload so the refusal message survives, and pre-check impacts to build the payload:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ const std::string qc = qualify(project, coll);
|
|
|
|
|
+ std::string err;
|
|
|
|
|
+ if (!d->db.client().remove(qc, id, err)) {
|
|
|
|
|
+ // A restrict relation is the one refusal a caller can act on, so it
|
|
|
|
|
+ // gets 409 plus the impact list rather than a generic failure.
|
|
|
|
|
+ auto why = d->db.client().describeDelete(qc, id);
|
|
|
|
|
+ if (why.success && why.wouldBeBlocked)
|
|
|
|
|
+ throw ApiError(ErrCode::Conflict, "relation_restricted",
|
|
|
|
|
+ err.empty() ? "delete blocked by a relation" : err,
|
|
|
|
|
+ {{"impacts", impactsJson(project, why)}});
|
|
|
|
|
+ throw ApiError(ErrCode::NotFound, "not_found", "document not found");
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Keep whatever success response the handler already sends.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Delete*:*Relation*'`
|
|
|
|
|
+Expected: PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/handlers/relations.cpp src/handlers/documents.cpp src/server.hpp tests/test_api_integration.cpp
|
|
|
|
|
+git commit -m "feat(relations): delete-impact, enforcement toggle and 409 on blocked delete"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 9: Admin read-only lock
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/handlers/settings.cpp`
|
|
|
|
|
+- Test: `tests/test_api_integration.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Produces: `GET /api/v1/admin/readonly` -> `{readonly: bool}`; `PUT /api/v1/admin/readonly {readonly: bool}` -> same. Server-global, not per project.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, AdminReadonlyLockBlocksWritesThenReleases) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","rolock"},{"kind","json"}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ std::string docs = base + "/rolock/documents";
|
|
|
|
|
+
|
|
|
|
|
+ auto on = c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", true}}.dump(),
|
|
|
|
|
+ "application/json");
|
|
|
|
|
+ ASSERT_TRUE(on); EXPECT_EQ(on->status, 200);
|
|
|
|
|
+ EXPECT_TRUE(nlohmann::json::parse(c.Get("/api/v1/admin/readonly")->body)["readonly"].get<bool>());
|
|
|
|
|
+
|
|
|
|
|
+ auto blocked = c.Post(docs.c_str(), nlohmann::json{{"v",1}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(blocked); EXPECT_NE(blocked->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ auto off = c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", false}}.dump(),
|
|
|
|
|
+ "application/json");
|
|
|
|
|
+ ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
|
|
|
|
|
+ EXPECT_EQ(c.Post(docs.c_str(), nlohmann::json{{"v",2}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, ReadonlyRequiresAdmin) {
|
|
|
|
|
+ // The fixture's scoped-key helper is used by the existing scoped-key tests;
|
|
|
|
|
+ // reuse the same construction to assert a non-admin key is refused.
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ auto made = c.Post("/api/v1/keys", nlohmann::json{
|
|
|
|
|
+ {"label","ro-nonadmin"}, {"projects", nlohmann::json::array({project_})},
|
|
|
|
|
+ {"admin", false}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_EQ(made->status, 201);
|
|
|
|
|
+ std::string nonAdmin = nlohmann::json::parse(made->body)["key"];
|
|
|
|
|
+ auto r = request("PUT", "/api/v1/admin/readonly",
|
|
|
|
|
+ nlohmann::json{{"readonly", true}}.dump(), "application/json", nonAdmin);
|
|
|
|
|
+ ASSERT_TRUE(r); EXPECT_EQ(r->status, 403);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Readonly*'`
|
|
|
|
|
+Expected: FAIL with 404 — routes not registered.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+Add to `registerSettingsRoutes` in `src/handlers/settings.cpp`. The lock is **server-global**, so the response says so explicitly rather than implying project scope:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ svr.Get(R"(/api/v1/admin/readonly)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ requireAdmin(k);
|
|
|
|
|
+ auto stats = d->db.client().getStats();
|
|
|
|
|
+ sendJson(res, 200, {{"readonly", stats.readOnly}, {"scope", "server"}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Put(R"(/api/v1/admin/readonly)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ requireAdmin(k);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ if (!body.contains("readonly") || !body["readonly"].is_boolean())
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "validation", "readonly (boolean) is required");
|
|
|
|
|
+ const bool ro = body["readonly"].get<bool>();
|
|
|
|
|
+ if (!d->db.client().setReadOnly(ro))
|
|
|
|
|
+ throw ApiError(ErrCode::Unavailable, "db_error", "failed to set read-only mode");
|
|
|
|
|
+ sendJson(res, 200, {{"readonly", ro}, {"scope", "server"}});
|
|
|
|
|
+ });
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Check what `getStats()` actually exposes for the read-only flag (`client.hpp` around the Stats struct). If it carries no such field, track the last set value in `ServerDeps` instead and document that the GET reports vectorapi's view, not the server's.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Readonly*'`
|
|
|
|
|
+Expected: PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/handlers/settings.cpp tests/test_api_integration.cpp
|
|
|
|
|
+git commit -m "feat(admin): server read-only lock endpoint"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 10: Report the loaded client library version
|
|
|
|
|
+
|
|
|
|
|
+The point of this endpoint is that it reports what the dynamic linker **bound**, not what the binary was compiled against — the distinction that makes a stale `.so` diagnosable.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/handlers/stats.cpp`
|
|
|
|
|
+- Test: `tests/test_api_integration.cpp`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Produces: `GET /api/v1/stats` gains `db_client_version` (e.g. `"2.11.1"`) and `db_client_commit` (e.g. `"320dfca"`).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, StatsReportsLoadedClientLibraryVersion) {
|
|
|
|
|
+ auto r = admin().Get("/api/v1/stats");
|
|
|
|
|
+ ASSERT_TRUE(r); ASSERT_EQ(r->status, 200);
|
|
|
|
|
+ auto body = nlohmann::json::parse(r->body);
|
|
|
|
|
+ ASSERT_TRUE(body.contains("db_client_version"));
|
|
|
|
|
+ ASSERT_TRUE(body.contains("db_client_commit"));
|
|
|
|
|
+ // Reports the LOADED library, so it must be non-empty and 2.x.
|
|
|
|
|
+ EXPECT_FALSE(body["db_client_version"].get<std::string>().empty());
|
|
|
|
|
+ EXPECT_EQ(body["db_client_version"].get<std::string>().rfind("2.", 0), 0u);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*StatsReports*'`
|
|
|
|
|
+Expected: FAIL — keys absent.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+In `src/handlers/stats.cpp`, inside the `/api/v1/stats` handler, add to the response object:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ // What the dynamic linker actually bound, not what we compiled against:
|
|
|
|
|
+ // a stale .so under an unchanged soname is otherwise invisible.
|
|
|
|
|
+ out["db_client_version"] = smartbotic::database::clientLibraryVersion();
|
|
|
|
|
+ out["db_client_commit"] = smartbotic::database::clientLibraryCommit();
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(Adapt `out` to whatever the handler names its response json.)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*StatsReports*'`
|
|
|
|
|
+Expected: PASS, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Full suite**
|
|
|
|
|
+
|
|
|
|
|
+Run: `ctest --test-dir build --output-on-failure 2>&1 | tail -5`
|
|
|
|
|
+Expected: all pass, no skips.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add src/handlers/stats.cpp tests/test_api_integration.cpp
|
|
|
|
|
+git commit -m "feat(stats): report loaded db client library version and commit"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 11: API documentation (openapi.json + llms.txt)
|
|
|
|
|
+
|
|
|
|
|
+CLAUDE.md requires these stay in sync; they are shipped in the server deb and served at `/docs` and `/llms.txt`.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `api/openapi.json`
|
|
|
|
|
+- Modify: `api/llms.txt`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: every route added in Tasks 3-10.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: List the routes that must appear**
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /api/v1/projects/{project}/collections/{collection}/indexes
|
|
|
|
|
+GET /api/v1/projects/{project}/collections/{collection}/indexes
|
|
|
|
|
+DELETE /api/v1/projects/{project}/collections/{collection}/indexes/{field}
|
|
|
|
|
+GET /api/v1/projects/{project}/collections/{collection}/indexes/{field}/values
|
|
|
|
|
+GET /api/v1/projects/{project}/collections/{collection}/documents/{id}/delete-impact
|
|
|
|
|
+PUT /api/v1/projects/{project}/collections/{collection}/relations-enforced
|
|
|
|
|
+POST /api/v1/projects/{project}/relations
|
|
|
|
|
+GET /api/v1/projects/{project}/relations
|
|
|
|
|
+GET /api/v1/projects/{project}/relations/{name}
|
|
|
|
|
+DELETE /api/v1/projects/{project}/relations/{name}
|
|
|
|
|
+GET /api/v1/admin/readonly
|
|
|
|
|
+PUT /api/v1/admin/readonly
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Plus: the `ttl_seconds` query parameter on document POST and PATCH, and the two new `/api/v1/stats` response fields.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Add each path to `api/openapi.json`**
|
|
|
|
|
+
|
|
|
|
|
+Follow the existing entries' shape exactly (same `tags`, `security`, and error-response refs). Document, in the descriptions:
|
|
|
|
|
+- `/indexes/{field}/values` returns an empty list for an unindexed or array-valued field — this is by design, not an error.
|
|
|
|
|
+- `ttl_seconds` on PATCH: **absent** leaves the expiry untouched; **0 clears** it.
|
|
|
|
|
+- 409 responses: `duplicate_values` (with `details.duplicate_examples`) and `relation_restricted` (with `details.impacts`).
|
|
|
|
|
+- `/admin/readonly` is server-global, not per project.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Add a 409 response component**
|
|
|
|
|
+
|
|
|
|
|
+If the spec file has a shared error-response section, add a `Conflict` entry there and reference it from the index-create and document-delete operations rather than repeating the schema.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Update `api/llms.txt`**
|
|
|
|
|
+
|
|
|
|
|
+Add the same routes in the existing llmstxt.org style, one line each with the one-sentence purpose. Keep the existing ordering convention.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Validate the spec parses**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 -c "import json;d=json.load(open('api/openapi.json'));print(len(d['paths']),'paths')"
|
|
|
|
|
+```
|
|
|
|
|
+Expected: parses, and the count increased by 10 new path entries (the two `/admin/readonly` verbs share one path, as do the paired GET/POST and GET/DELETE paths).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add api/openapi.json api/llms.txt
|
|
|
|
|
+git commit -m "docs(api): document index, facet, ttl, relation and readonly routes"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 12: Web UI index management
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `webui/src/api/client.ts` (new API methods)
|
|
|
|
|
+- Modify: `webui/src/types.ts` (new types)
|
|
|
|
|
+- Create: `webui/src/components/IndexPanel.tsx`
|
|
|
|
|
+- Modify: `webui/src/pages/Collections.tsx` (mount the panel)
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: the index routes from Tasks 3-5.
|
|
|
|
|
+- Produces: `api.listIndexes/createIndex/dropIndex/indexValues`; `<IndexPanel project collection />`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Add types**
|
|
|
|
|
+
|
|
|
|
|
+In `webui/src/types.ts` (no TS `enum`, no `any`):
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+export type IndexDef = {
|
|
|
|
|
+ field: string
|
|
|
|
|
+ distinct_values: number
|
|
|
|
|
+ entries: number
|
|
|
|
|
+ unique: boolean
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export type IndexValue = { value: unknown; count: number }
|
|
|
|
|
+
|
|
|
|
|
+export type CreateIndexResult = {
|
|
|
|
|
+ field: string
|
|
|
|
|
+ unique: boolean
|
|
|
|
|
+ rows_indexed: number
|
|
|
|
|
+ already_existed: boolean
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Add API methods**
|
|
|
|
|
+
|
|
|
|
|
+In `webui/src/api/client.ts`, inside the `api` object, following the existing `P(project)` helper style:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+ listIndexes: (project: string, coll: string) =>
|
|
|
|
|
+ request<{ indexes: IndexDef[] }>(`${P(project)}/collections/${enc(coll)}/indexes`),
|
|
|
|
|
+ createIndex: (project: string, coll: string, field: string, unique: boolean) =>
|
|
|
|
|
+ request<CreateIndexResult>(`${P(project)}/collections/${enc(coll)}/indexes`,
|
|
|
|
|
+ { method: 'POST', body: JSON.stringify({ field, unique }) }),
|
|
|
|
|
+ dropIndex: (project: string, coll: string, field: string) =>
|
|
|
|
|
+ request<{ dropped: string }>(`${P(project)}/collections/${enc(coll)}/indexes/${enc(field)}`,
|
|
|
|
|
+ { method: 'DELETE' }),
|
|
|
|
|
+ indexValues: (project: string, coll: string, field: string, limit = 20) =>
|
|
|
|
|
+ request<{ values: IndexValue[] }>(
|
|
|
|
|
+ `${P(project)}/collections/${enc(coll)}/indexes/${enc(field)}/values?limit=${limit}`),
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add the new types to the existing `import type { ... } from '@/types'` list at the top.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Build the panel**
|
|
|
|
|
+
|
|
|
|
|
+Create `webui/src/components/IndexPanel.tsx`. It must render the 409 `duplicate_examples` as real content, not a bare error — that payload is the actionable part:
|
|
|
|
|
+
|
|
|
|
|
+```tsx
|
|
|
|
|
+import { useState } from 'react'
|
|
|
|
|
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
+import { api } from '@/api/client'
|
|
|
|
|
+import { ApiError } from '@/types'
|
|
|
|
|
+
|
|
|
|
|
+export function IndexPanel({ project, collection }: { project: string; collection: string }) {
|
|
|
|
|
+ const qc = useQueryClient()
|
|
|
|
|
+ const [field, setField] = useState('')
|
|
|
|
|
+ const [unique, setUnique] = useState(false)
|
|
|
|
|
+ const [duplicates, setDuplicates] = useState<string[]>([])
|
|
|
|
|
+
|
|
|
|
|
+ const key = ['indexes', project, collection]
|
|
|
|
|
+ const { data, isLoading } = useQuery({
|
|
|
|
|
+ queryKey: key,
|
|
|
|
|
+ queryFn: () => api.listIndexes(project, collection),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const create = useMutation({
|
|
|
|
|
+ mutationFn: () => api.createIndex(project, collection, field, unique),
|
|
|
|
|
+ onSuccess: () => { setField(''); setDuplicates([]); qc.invalidateQueries({ queryKey: key }) },
|
|
|
|
|
+ onError: (e: unknown) => {
|
|
|
|
|
+ const det = e instanceof ApiError ? e.details : undefined
|
|
|
|
|
+ const ex = det && typeof det === 'object' && 'duplicate_examples' in det
|
|
|
|
|
+ ? (det as { duplicate_examples: string[] }).duplicate_examples
|
|
|
|
|
+ : []
|
|
|
|
|
+ setDuplicates(ex)
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const drop = useMutation({
|
|
|
|
|
+ mutationFn: (f: string) => api.dropIndex(project, collection, f),
|
|
|
|
|
+ onSuccess: () => qc.invalidateQueries({ queryKey: key }),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="rounded-lg border border-slate-700 p-4">
|
|
|
|
|
+ <h3 className="text-sm font-semibold text-slate-200 mb-3">Indexes</h3>
|
|
|
|
|
+
|
|
|
|
|
+ {isLoading ? <p className="text-sm text-slate-400">Loading…</p> : (
|
|
|
|
|
+ <table className="w-full text-sm mb-4">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr className="text-left text-slate-400">
|
|
|
|
|
+ <th className="py-1">Field</th><th>Unique</th><th>Distinct</th><th>Entries</th><th />
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {(data?.indexes ?? []).map((i) => (
|
|
|
|
|
+ <tr key={i.field} className="border-t border-slate-800">
|
|
|
|
|
+ <td className="py-1 text-slate-200">{i.field}</td>
|
|
|
|
|
+ <td>{i.unique ? 'yes' : 'no'}</td>
|
|
|
|
|
+ <td>{i.distinct_values}</td>
|
|
|
|
|
+ <td>{i.entries}</td>
|
|
|
|
|
+ <td className="text-right">
|
|
|
|
|
+ <button onClick={() => drop.mutate(i.field)}
|
|
|
|
|
+ className="text-red-400 hover:text-red-300">Drop</button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ {(data?.indexes ?? []).length === 0 && (
|
|
|
|
|
+ <tr><td colSpan={5} className="py-2 text-slate-500">No indexes declared.</td></tr>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <input value={field} onChange={(e) => setField(e.target.value)} placeholder="field name"
|
|
|
|
|
+ className="rounded bg-slate-800 px-2 py-1 text-sm text-slate-200" />
|
|
|
|
|
+ <label className="flex items-center gap-1 text-sm text-slate-300">
|
|
|
|
|
+ <input type="checkbox" checked={unique} onChange={(e) => setUnique(e.target.checked)} />
|
|
|
|
|
+ unique
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <button disabled={!field || create.isPending} onClick={() => create.mutate()}
|
|
|
|
|
+ className="rounded bg-indigo-600 px-3 py-1 text-sm text-white disabled:opacity-50">
|
|
|
|
|
+ Create
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {duplicates.length > 0 && (
|
|
|
|
|
+ <div className="mt-3 rounded border border-red-800 bg-red-950/40 p-2 text-sm">
|
|
|
|
|
+ <p className="text-red-300">
|
|
|
|
|
+ Cannot make this field unique — these documents hold duplicate values:
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <ul className="mt-1 list-disc pl-5 font-mono text-xs text-red-200">
|
|
|
|
|
+ {duplicates.map((id) => <li key={id}>{id}</li>)}
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Expose `details` on the client-side ApiError**
|
|
|
|
|
+
|
|
|
|
|
+In `webui/src/types.ts`, extend `ApiError` to carry `details`, and in `webui/src/api/client.ts` pass `body?.error?.details` when constructing it. Without this the duplicate list never reaches the panel.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Mount it**
|
|
|
|
|
+
|
|
|
|
|
+In `webui/src/pages/Collections.tsx`, render `<IndexPanel project={currentProject} collection={selected} />` in the per-collection detail area, following how that page already reads `currentProject` from `useAuthStore`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Build and typecheck**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd webui && npm run build
|
|
|
|
|
+```
|
|
|
|
|
+Expected: build succeeds with no TS errors. Confirm the emitted `dist/index.html` still references `./assets/...` (relative), preserving the subpath fix.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add webui/src/types.ts webui/src/api/client.ts webui/src/components/IndexPanel.tsx webui/src/pages/Collections.tsx
|
|
|
|
|
+git commit -m "feat(webui): index management panel with duplicate reporting"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 13: Release 0.2.0
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `VERSION`
|
|
|
|
|
+- Modify: `packaging/Dockerfile.base` (only if it pins a client-dev version explicitly)
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: everything above.
|
|
|
|
|
+- Produces: `smartbotic-vectorapi_0.2.0-1_amd64.deb` and `smartbotic-vectorapi-webui_0.2.0-1_all.deb`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Full verification before packaging**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cmake -B build -G Ninja -DBUILD_TESTS=ON -DBUILD_WEBUI=ON -S . && cmake --build build -j"$(nproc)"
|
|
|
|
|
+ctest --test-dir build --output-on-failure 2>&1 | tail -5
|
|
|
|
|
+```
|
|
|
|
|
+Expected: all pass. **Check explicitly for "Skipped" — a skip here means the integration tests never ran.**
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Bump the version**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+echo "0.2.0" > VERSION
|
|
|
|
|
+git add VERSION && git commit -m "build(release): bump version to 0.2.0"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Rebuild the Docker base so it links the 2.11.1 client**
|
|
|
|
|
+
|
|
|
|
|
+A stale base image silently produces a 2.4.0-linked binary — the exact ABI hazard this release exists to resolve.
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./packaging/build.sh --rebuild-base
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Verify the built binary links the NEW client**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+dpkg-deb -x dist/debian13/smartbotic-vectorapi_0.2.0-1_amd64.deb /tmp/v020
|
|
|
|
|
+nm -D --undefined-only /tmp/v020/usr/bin/smartbotic-vectorapi | c++filt | grep -c 'smartbotic::database::Client'
|
|
|
|
|
+dpkg-deb -I dist/debian13/smartbotic-vectorapi_0.2.0-1_amd64.deb | grep Depends
|
|
|
|
|
+```
|
|
|
|
|
+Expected: symbol count is greater than the 23 of 0.1.7 (new APIs are now used), and `Depends` names `libsmartbotic-db-client`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Confirm the webui dependency still resolves**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+dpkg-deb -I dist/debian13/smartbotic-vectorapi-webui_0.2.0-1_all.deb | grep Depends
|
|
|
|
|
+```
|
|
|
|
|
+Expected: `Depends: smartbotic-vectorapi (>= 0.2.0)` — upstream version without the deb revision.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: STOP — do not publish**
|
|
|
|
|
+
|
|
|
|
|
+Publishing is outward-facing and needs explicit confirmation. The live upgrade is a separate, gated operation covered by `2026-08-14-database-2.11.1-upgrade-design.md`: fresh backup, `apt-mark unhold`, install DB 2.11.1 + client + vectorapi 0.2.0 + webui 0.2.0 together, verify, **deliberately restart vectorapi and re-verify**, re-apply holds.
|
|
|
|
|
+
|
|
|
|
|
+Hand back to the user with the built artifacts and the upgrade checklist.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Self-Review
|
|
|
|
|
+
|
|
|
|
|
+**Spec coverage:** indexes (T3), unique + 409 (T4), facets (T5), TTL (T6), relations CRUD (T7), delete-impact + enforcement + 409 (T8), read-only lock (T9), client version (T10), 409/details plumbing (T2), docs (T11), webui (T12), release + base rebuild (T13), dev-env DB requirement (T1). No spec section is unimplemented.
|
|
|
|
|
+
|
|
|
|
|
+**Known verification points** (call these out during execution rather than guessing):
|
|
|
|
|
+- The second `listIndexes` overload's out-parameter type (T3).
|
|
|
|
|
+- Which `createRelation` overload takes `validateOnWrite` (T7).
|
|
|
|
|
+- Whether `getStats()` exposes a read-only flag (T9).
|
|
|
|
|
+
|
|
|
|
|
+These are the three places where the header must be re-read before writing the call; every other signature in this plan was confirmed against the 2.11.1 headers or exercised directly in the zeus rehearsal.
|