// v2.3 — project addressing. // // Collections live inside a "project" namespace. On the wire, an // unqualified name like "users" resolves to ("default", "users"); an // explicit name like "my_app:users" resolves to ("my_app", "users"). // The default project is auto-created on first boot and is the v2.2 // back-compat path: every existing client that doesn't set a project // addresses "default" and observes the same data it always did. // // Storage maps one project → one LmdbEnv at /projects//env/. // gRPC handlers parse the input here and dispatch the rest of the call // against the resolved project store. // // Constraints (mirrors the collection-name rules so the joined wire form // stays unambiguous): // - project name: ^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$ (1..63 chars) // - collection name: same rules as v2.2 (already enforced upstream) // - separator is a single ':' (configurable below if we ever need to) // // Reserved project names: // - "default" is always present and addressable; recreating it via // CreateProject is a no-op (idempotent). // - Names starting with '_' are reserved for system use, same as // collection sub-db names. #pragma once #include #include #include namespace smartbotic::database { constexpr const char* kDefaultProject = "default"; constexpr char kProjectSeparator = ':'; constexpr size_t kMaxProjectNameLen = 63; struct ProjectCollection { std::string project; std::string collection; }; // Edge-case semantics (chosen to fail loud rather than silently coerce): // "" -> std::invalid_argument // "users" -> ("default", "users") // "my_app:users" -> ("my_app", "users") // ":users" -> std::invalid_argument (empty project before ':' is a // user error — they typed the separator intentionally) // "my_app:" -> std::invalid_argument (empty collection) // "a:b:c" -> std::invalid_argument (multiple separators) // // The returned `project` is guaranteed non-empty and the `collection` is // guaranteed non-empty. Project-name validation is NOT done here — the // caller calls isValidProjectName() at create-time, so reads of an // invalid-but-existing project name still succeed via the parser (the // invalid name would have failed validation at create time). inline ProjectCollection parseProjectCollection(std::string_view input) { if (input.empty()) { throw std::invalid_argument("project-collection: empty input"); } const size_t pos = input.find(kProjectSeparator); if (pos == std::string_view::npos) { return {kDefaultProject, std::string(input)}; } if (pos == 0) { throw std::invalid_argument( "project-collection: ':' separator at position 0 (empty project) — " "use an unqualified name to address the default project"); } if (pos == input.size() - 1) { throw std::invalid_argument( "project-collection: empty collection after ':' separator"); } // Reject multiple separators — ambiguous. if (input.find(kProjectSeparator, pos + 1) != std::string_view::npos) { throw std::invalid_argument( "project-collection: only one ':' separator allowed"); } return {std::string(input.substr(0, pos)), std::string(input.substr(pos + 1))}; } // One-call helper for gRPC handlers. Parses + builds the qualified form // in one go. The qualified form is what MemoryStore uses as its // collection key in v2.3+ (the bare collection name is what each // per-project LMDB DocumentStore sees). // // ResolvedCollection rc = resolveCollection("my_app:users"); // // rc.project = "my_app" // // rc.collection = "users" // // rc.qualified = "my_app:users" // // ResolvedCollection rc = resolveCollection("users"); // // rc.project = "default" // // rc.collection = "users" // // rc.qualified = "default:users" struct ResolvedCollection { std::string project; std::string collection; std::string qualified; }; inline ResolvedCollection resolveCollection(std::string_view input) { auto pc = parseProjectCollection(input); std::string qualified; qualified.reserve(pc.project.size() + 1 + pc.collection.size()); qualified.append(pc.project); qualified.push_back(kProjectSeparator); qualified.append(pc.collection); return {std::move(pc.project), std::move(pc.collection), std::move(qualified)}; } // True iff `name` matches the project-name regex AND is not a reserved // system name. Used by CreateProject + the lazy first-write path. inline bool isValidProjectName(std::string_view name) { if (name.empty() || name.size() > kMaxProjectNameLen) return false; if (name[0] == '_') return false; // reserved auto valid_first = [](char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; }; auto valid_rest = [](char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; }; if (!valid_first(name[0])) return false; for (size_t i = 1; i < name.size(); ++i) { if (!valid_rest(name[i])) return false; } return true; } } // namespace smartbotic::database