Files
EVEngine/src/modules/editor/EditorDiskDocumentStore.cpp
T

227 lines
10 KiB
C++

#include "editor/EditorDiskDocumentStore.h"
#include "editor/EditorValueJson.h"
#include <algorithm>
#include <cctype>
#include <fstream>
#include <iterator>
#if defined(_WIN32)
#include <windows.h>
#endif
namespace eve::editor {
namespace {
const EditorValue* member(const EditorValue& value, const char* key) {
const auto* object = value.getIf<EditorValue::Object>();
if (!object) return nullptr;
const auto found = object->find(key);
return found == object->end() ? nullptr : &found->second;
}
std::string stringMember(const EditorValue& value, const char* key) {
const EditorValue* field = member(value, key);
const auto* text = field ? field->getIf<std::string>() : nullptr;
return text ? *text : std::string{};
}
Revision revisionMember(const EditorValue& value, const char* key) {
const EditorValue* field = member(value, key);
const auto* integer = field ? field->getIf<std::int64_t>() : nullptr;
return integer && *integer >= 0 ? static_cast<Revision>(*integer) : 0;
}
EditorResult<AutosaveDraft> draftFailure(const char* rule, std::string message) {
return EditorResult<AutosaveDraft>::error(EditorStatus::Rejected, RuleId(rule), std::move(message));
}
} // namespace
DiskAtomicDocumentStore::DiskAtomicDocumentStore(std::filesystem::path projectRoot) {
std::error_code ec;
root_ = std::filesystem::weakly_canonical(std::move(projectRoot), ec);
if (ec) root_.clear();
}
std::filesystem::path DiskAtomicDocumentStore::resolve(const std::string& resourceUri) const {
if (root_.empty()) return {};
std::string relative;
if (resourceUri.rfind("content://", 0) == 0)
relative = "Content/" + resourceUri.substr(10);
else if (resourceUri.rfind("autosave://", 0) == 0)
relative = "Saved/Autosave/" + resourceUri.substr(11);
else
return {};
if (relative.empty()) return {};
std::filesystem::path candidate(relative);
if (candidate.is_absolute()) return {};
for (const auto& part : candidate)
if (part == "..") return {};
candidate = (root_ / candidate).lexically_normal();
const std::filesystem::path scoped = candidate.lexically_relative(root_);
if (scoped.empty()) return {};
for (const auto& part : scoped)
if (part == "..") return {};
return candidate;
}
EditorResult<StoredDocument> DiskAtomicDocumentStore::read(const std::string& resourceUri) const {
const std::filesystem::path path = resolve(resourceUri);
if (path.empty()) return failure(EditorStatus::Rejected, "editor.document.invalid-uri", "Rejected resource URI");
std::ifstream input(path, std::ios::binary);
if (!input)
return failure(EditorStatus::NotFound, "editor.document.store-not-found",
"Document resource is not persisted: " + resourceUri);
const std::string json{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
EditorResult<EditorValue> parsed = editorValueFromJson(json);
if (!parsed.isAccepted() || !parsed.value)
return failure(EditorStatus::Failed, "editor.document.invalid-envelope", "Document JSON envelope is invalid");
const EditorValue* content = member(*parsed.value, "content");
const Revision revision = revisionMember(*parsed.value, "revision");
const auto* schema = member(*parsed.value, "schemaVersion");
const auto* schemaInteger = schema ? schema->getIf<std::int64_t>() : nullptr;
if (!content || !schemaInteger || *schemaInteger != 1)
return failure(EditorStatus::Failed, "editor.document.unsupported-schema",
"Document envelope schemaVersion must be 1");
StoredDocument stored;
stored.content = *content;
stored.revision = revision;
stored.contentHash = editorValueContentHash(stored.content);
return EditorResult<StoredDocument>::applied(std::move(stored));
}
EditorResult<StoredDocument> DiskAtomicDocumentStore::compareAndSwap(const std::string& resourceUri,
Revision expectedRevision,
const std::string& expectedContentHash,
const EditorValue& content) {
const std::filesystem::path path = resolve(resourceUri);
if (path.empty()) return failure(EditorStatus::Rejected, "editor.document.invalid-uri", "Rejected resource URI");
StoredDocument current;
EditorResult<StoredDocument> existing = read(resourceUri);
if (existing.isAccepted() && existing.value)
current = *existing.value;
else if (existing.status != EditorStatus::NotFound)
return existing;
else
current.contentHash.clear();
if (current.revision != expectedRevision || current.contentHash != expectedContentHash)
return failure(EditorStatus::Conflict, "editor.document.disk-conflict",
"Document content or revision changed before save");
StoredDocument next;
next.content = content;
next.revision = current.revision + 1;
next.contentHash = editorValueContentHash(content);
EditorValue::Object envelope;
envelope["schemaVersion"] = EditorValue(1);
envelope["revision"] = EditorValue(static_cast<std::int64_t>(next.revision));
envelope["contentHash"] = EditorValue(next.contentHash);
envelope["content"] = content;
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) return failure(EditorStatus::Failed, "editor.document.create-directory", ec.message());
const std::filesystem::path temporary = path.string() + ".tmp." + std::to_string(++sequence_);
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) return failure(EditorStatus::Failed, "editor.document.temp-open", "Cannot open temp file");
output << editorValueToJson(EditorValue(std::move(envelope)));
output.flush();
if (!output.good()) {
output.close();
std::filesystem::remove(temporary, ec);
return failure(EditorStatus::Failed, "editor.document.temp-write", "Cannot flush temp file");
}
}
if ((beforeReplace_ && !beforeReplace_(path)) || !replace(temporary, path)) {
std::filesystem::remove(temporary, ec);
return failure(EditorStatus::Failed, "editor.document.atomic-replace", "Atomic replacement failed");
}
return EditorResult<StoredDocument>::applied(std::move(next));
}
EditorResult<StoredDocument> DiskAtomicDocumentStore::failure(EditorStatus status, const char* rule,
std::string message) {
return EditorResult<StoredDocument>::error(status, RuleId(rule), std::move(message));
}
bool DiskAtomicDocumentStore::replace(const std::filesystem::path& temporary,
const std::filesystem::path& destination) const {
#if defined(_WIN32)
return MoveFileExW(temporary.c_str(), destination.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
#else
std::error_code ec;
std::filesystem::rename(temporary, destination, ec);
return !ec;
#endif
}
std::string AutosaveService::draftUri(const DocumentId& document) const {
std::string safeDocument = document.value();
std::replace_if(
safeDocument.begin(), safeDocument.end(),
[](unsigned char character) { return !std::isalnum(character) && character != '-' && character != '_'; }, '_');
return "autosave://" + projectId_ + "/" + safeDocument + ".draft";
}
EditorResult<StoredDocument> AutosaveService::writeDraft(const DocumentSnapshot& snapshot, const EditorValue& content) {
if (!store_ || snapshot.id.empty())
return EditorResult<StoredDocument>::error(EditorStatus::Rejected, RuleId("editor.autosave.invalid"),
"Autosave store and document are required");
const std::string uri = draftUri(snapshot.id);
Revision revision = 0;
std::string hash;
const EditorResult<StoredDocument> existing = store_->read(uri);
if (existing.isAccepted() && existing.value) {
revision = existing.value->revision;
hash = existing.value->contentHash;
} else if (existing.status != EditorStatus::NotFound) {
return existing;
}
EditorValue::Object draft;
draft["document"] = EditorValue(snapshot.id.value());
draft["baseDiskHash"] = EditorValue(snapshot.diskContentHash);
draft["schemaVersion"] = EditorValue(1);
draft["editRevision"] = EditorValue(static_cast<std::int64_t>(snapshot.revision.edit));
draft["content"] = content;
return store_->compareAndSwap(uri, revision, hash, EditorValue(std::move(draft)));
}
EditorResult<AutosaveDraft> AutosaveService::readDraft(const DocumentId& document) const {
if (!store_ || document.empty()) return draftFailure("editor.autosave.invalid", "Autosave store is required");
const EditorResult<StoredDocument> stored = store_->read(draftUri(document));
if (!stored.isAccepted() || !stored.value) {
EditorResult<AutosaveDraft> result;
result.status = stored.status;
result.diagnostics = stored.diagnostics;
return result;
}
const EditorValue& root = stored.value->content;
const EditorValue* content = member(root, "content");
if (!content || revisionMember(root, "schemaVersion") != 1)
return draftFailure("editor.autosave.unsupported-schema", "Autosave draft schemaVersion must be 1");
AutosaveDraft draft;
draft.document = DocumentId(stringMember(root, "document"));
draft.baseDiskHash = stringMember(root, "baseDiskHash");
draft.schemaVersion = 1;
draft.editRevision = revisionMember(root, "editRevision");
draft.content = *content;
if (draft.document != document)
return draftFailure("editor.autosave.document-mismatch", "Autosave draft belongs to another document");
return EditorResult<AutosaveDraft>::applied(std::move(draft));
}
bool AutosaveService::shouldOfferRecovery(const AutosaveDraft& draft, const DocumentSnapshot& formal) const {
return draft.document == formal.id && draft.editRevision > formal.revision.saved &&
draft.baseDiskHash == formal.diskContentHash;
}
} // namespace eve::editor