package: one bank leaves the project as one .rsbank, or the export refuses and says why

Pure planner classifies missing/unreadable/unrepresentable and repairs transport
names; the verb digests, streams and commits atomically over a const session.
This commit is contained in:
2026-08-02 13:21:43 -04:00
parent 33ea95078d
commit 081b6f1028
16 changed files with 1426 additions and 2 deletions
+5 -1
View File
@@ -54,7 +54,10 @@ belong to the verbs.
`PackageFileWriter` — after any extension append — and get its own consent if that
re-checked path is `Present`; the dialog's confirm only ever covered the pre-append
path. Not fixed at this seam: prompting is verb-level UX, and `pickPackageSavePath`
has no caller yet, so the gap is latent, not live.
has no caller yet, so the gap is latent, not live. **Closed on the export side:**
`exportBank` re-checks `fileStatus()` on the post-append path and refuses
`RefusedDestinationExists` until the caller sets `allowOverwrite`, which
`package_export_action` does only after its own confirm naming that exact path.
- **The rollback delete is prune's ONE carve-out, and only HALF of it is structural.**
The citation and the full discriminator live at `package_rollback.cpp`'s header.
"Did this call create it" is structural: only exclusively-created paths are
@@ -77,6 +80,7 @@ belong to the verbs.
- `package_io` — every filesystem act the verbs need: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), `fileStatus` (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), `writeFileExclusive` (exclusive create + write, self-cleaning on a partial write), and `listFolderFileNames` (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.
- `package_rollback``LandedFileJournal`: `writeLandedFile` (exclusive-create land, path resolved absolute, recorded on success only), `markIndexCommitted` (disarms the journal), and `rollback` (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW.
- `package_pickers``pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`. Compile-only until the verbs land; nothing here can be exercised in a unit test.
- `export_bank` — the promptless export verb, in three composable public steps: `surveyBankExport` (the read-only plan, report-before-acting), `digestSources` (measures each entry's length + `hashBytes` digest, one payload at a time), and `writePackageFile` (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). `exportBank` composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives **const** — every mutator on it is non-const, so "an export writes no ext state, opens no undo point and never bumps the generation" is enforced by the type rather than remembered. Reads the session through inline accessors only, which is why its tests link and run without a DAW.
## Gotchas
+9
View File
@@ -9,6 +9,15 @@ reasampler_test(package_io LINK package_io)
reasampler_pure_library(package_rollback SOURCES package_rollback.cpp LINK PUBLIC package_io)
reasampler_test(package_rollback LINK package_rollback)
# export_bank reads the live session through ReaSamplerSession's INLINE accessors only,
# so it pulls in no REAPER-facing TU and its tests link (and run) without a DAW.
reasampler_pure_library(export_bank
SOURCES export_bank.cpp
LINK PUBLIC export_plan bank_package package_io PRIVATE capture_paths wav_codec)
reasampler_test(export_bank
LINK export_bank bank_book slot_map view_mode_model tail_control origin_ledger
tracking_authority prune_reconcile app_version capture_paths)
# The pickers call the REAPER API, so no test target can exercise them; declared as a
# library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows.
add_library(package_pickers STATIC package_pickers.cpp)
+184
View File
@@ -0,0 +1,184 @@
// export_bank.cpp — see export_bank.h for the contract.
//
// wav_codec is called for hashBytes ONLY. Payload bytes are copied and hashed, never
// rebuilt, trimmed, normalized or collapsed — the capture path's mono collapse must
// not reach an export.
#include "shell/package/export_bank.h"
#include <cstddef>
#include <optional>
#include <utility>
#include "core/capture/capture_paths.h" // resolveBankFile — the index's relative -> absolute
#include "core/capture/wav_codec.h" // hashBytes
#include "core/model/bank_book.h"
#include "shell/package/package_io.h"
#include "shell/persist/session.h" // ReaSamplerSession — read through its inline book() only
namespace reasampler {
namespace {
package::SourceFileState stateOf(const std::string& absPath) {
switch (fileStatus(absPath)) {
case FileStatus::Present: return package::SourceFileState::Present;
case FileStatus::Unreadable: return package::SourceFileState::Unreadable;
case FileStatus::Absent: break;
}
return package::SourceFileState::Missing;
}
std::vector<std::string> absoluteSources(const std::string& projectDir,
const std::vector<std::string>& relativePaths) {
std::vector<std::string> out;
out.reserve(relativePaths.size());
for (const std::string& rel : relativePaths)
out.push_back(capture::resolveBankFile(projectDir, rel));
return out;
}
} // namespace
ExportSurvey surveyBankExport(const ReaSamplerSession& session,
const std::string& projectDir,
const std::string& bankId) {
ExportSurvey survey;
const Bank* bank = session.book().bank(bankId);
if (!bank) return survey;
survey.bankFound = true;
package::ExportInputs inputs;
inputs.bankDisplayName = bank->displayName;
inputs.slots = bank->slots;
for (const model::Sample& s : bank->index.all()) {
package::ExportCandidate c;
c.sample = s;
c.fileState = stateOf(capture::resolveBankFile(projectDir, s.relativePath));
inputs.candidates.push_back(std::move(c));
}
survey.plan = package::planExport(inputs);
return survey;
}
bool digestSources(package::PackageManifest& manifest,
const std::vector<std::string>& sourceAbsPaths,
std::string& outFailedName) {
outFailedName.clear();
if (sourceAbsPaths.size() != manifest.entries.size()) return false;
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
const PayloadBuffer payload = readFilePayload(sourceAbsPaths[i]);
if (payload.empty()) {
outFailedName = manifest.entries[i].fileName;
return false;
}
manifest.entries[i].byteLength = payload.size();
manifest.entries[i].byteHash = capture::hashBytes(payload.data(), payload.size());
}
return true;
}
ExportOutcome writePackageFile(const package::EncodedPackage& encoded,
const package::PackageManifest& manifest,
const std::vector<std::string>& sourceAbsPaths,
const std::string& destAbsPath) {
ExportOutcome out;
if (sourceAbsPaths.size() != manifest.entries.size() ||
encoded.layout.size() != manifest.entries.size()) {
out.status = ExportStatus::EncodeFailed;
return out;
}
// Every early return below abandons the writer through its destructor, which
// removes the temp and leaves the destination untouched.
PackageFileWriter writer(destAbsPath);
if (!writer.ok() || !writer.appendRaw(encoded.prefix.data(), encoded.prefix.size())) {
out.status = ExportStatus::WriteFailed;
return out;
}
std::uint64_t written = encoded.prefix.size();
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
const package::PackageEntry& entry = manifest.entries[i];
const PayloadBuffer payload = readFilePayload(sourceAbsPaths[i]);
if (payload.empty()) {
out.status = ExportStatus::SourceReadFailed;
out.offendingName = entry.fileName;
return out;
}
if (payload.size() != entry.byteLength ||
capture::hashBytes(payload.data(), payload.size()) != entry.byteHash) {
out.status = ExportStatus::SourceChanged;
out.offendingName = entry.fileName;
return out;
}
if (!writer.appendPayload(payload)) {
out.status = ExportStatus::WriteFailed;
return out;
}
written += payload.size();
}
if (written != encoded.totalSize || !writer.commit()) {
out.status = ExportStatus::WriteFailed;
return out;
}
out.status = ExportStatus::Written;
out.entriesWritten = manifest.entries.size();
out.bytesWritten = written;
return out;
}
ExportOutcome exportBank(const ReaSamplerSession& session, const ExportRequest& req) {
ExportOutcome out;
if (req.projectDir.empty()) {
out.status = ExportStatus::NoProjectDir;
return out;
}
const ExportSurvey survey = surveyBankExport(session, req.projectDir, req.bankId);
if (!survey.bankFound) {
out.status = ExportStatus::NoSuchBank;
return out;
}
out.bankDisplayName = survey.plan.manifest.bankDisplayName;
out.excluded = survey.plan.excluded;
if (survey.plan.verdict == package::ExportVerdict::Refused) {
out.status = ExportStatus::RefusedUnrepresentable;
return out;
}
if (survey.plan.verdict == package::ExportVerdict::Incomplete && !req.allowIncomplete) {
out.status = ExportStatus::RefusedIncomplete;
return out;
}
// The save dialog's own overwrite confirm covered the path the USER chose, which
// is not necessarily the path handed here (the picker re-appends `.rsbank`), so
// consent for the real target is re-taken by the skin.
if (!req.allowOverwrite && fileStatus(req.destAbsPath) == FileStatus::Present) {
out.status = ExportStatus::RefusedDestinationExists;
return out;
}
package::PackageManifest manifest = survey.plan.manifest;
manifest.exportTimestamp = req.exportTimestamp;
const std::vector<std::string> sources =
absoluteSources(req.projectDir, survey.plan.sourceRelativePaths);
if (!digestSources(manifest, sources, out.offendingName)) {
out.status = ExportStatus::SourceReadFailed;
return out;
}
const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
if (!encoded) {
out.status = ExportStatus::EncodeFailed;
return out;
}
ExportOutcome written = writePackageFile(*encoded, manifest, sources, req.destAbsPath);
written.bankDisplayName = out.bankDisplayName;
written.excluded = std::move(out.excluded);
return written;
}
} // namespace reasampler
+91
View File
@@ -0,0 +1,91 @@
// shell/package/export_bank — the promptless bank-export verb: survey, digest,
// stream, commit. No prompts and no message boxes (shell/actions/
// package_export_action is the skin). The session arrives CONST, which is how "an
// export writes no ext state, opens no undo point and never bumps the bank
// generation" is enforced rather than remembered — every mutator on the session is
// non-const. Blocking I/O: UI-thread actions only.
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "core/package/bank_package.h"
#include "core/package/export_plan.h"
namespace reasampler {
class ReaSamplerSession;
struct ExportRequest {
std::string projectDir; // absolute; the root the index's relative paths hang off
std::string bankId;
std::string destAbsPath; // the .rsbank to write
std::int64_t exportTimestamp = 0; // manifest envelope; the caller's clock read
// Both default false and are set ONLY after the skin's explicit confirm: one
// lists what is absent, the other names the destination being replaced.
bool allowIncomplete = false;
bool allowOverwrite = false;
};
enum class ExportStatus {
Written,
NoSuchBank,
NoProjectDir,
RefusedIncomplete,
RefusedUnrepresentable,
RefusedDestinationExists,
SourceReadFailed, // a file the plan classified Present would not read, or is empty
SourceChanged, // a payload's bytes moved between the digest pass and the stream pass
EncodeFailed,
WriteFailed,
};
struct ExportOutcome {
ExportStatus status = ExportStatus::WriteFailed;
std::size_t entriesWritten = 0;
std::uint64_t bytesWritten = 0;
std::string bankDisplayName;
std::vector<package::ExcludedEntry> excluded;
std::string offendingName; // the entry a SourceReadFailed / SourceChanged names
};
struct ExportSurvey {
bool bankFound = false;
package::ExportPlan plan;
};
// Report-before-acting: the same plan exportBank recomputes, with nothing written.
// Read-only against both the project and the filesystem.
ExportSurvey surveyBankExport(const ReaSamplerSession& session,
const std::string& projectDir,
const std::string& bankId);
// Fills each manifest entry's byteLength and byteHash from its source file — the
// digest pass, one payload in memory at a time. False with `outFailedName` set when a
// source will not read or is empty; a zero-length entry cannot round-trip the
// format's own seam, so it is a failure here rather than an entry.
bool digestSources(package::PackageManifest& manifest,
const std::vector<std::string>& sourceAbsPaths,
std::string& outFailedName);
// Streams one package to `destAbsPath`: the encoded prefix, then each payload re-read
// from `sourceAbsPaths` (parallel to `manifest.entries`) and re-checked against the
// length and digest recorded for it before it is appended — so the digest the
// manifest claims describes the bytes actually written, not the bytes a concurrent
// edit replaced. Any failure abandons the writer, leaving the destination absent or
// holding its prior contents.
//
// Public because that atomicity is this function's property: proving it needs a
// failure injected mid-stream, which is a call to this seam, not to exportBank.
ExportOutcome writePackageFile(const package::EncodedPackage& encoded,
const package::PackageManifest& manifest,
const std::vector<std::string>& sourceAbsPaths,
const std::string& destAbsPath);
// The verb: plan, gate on the verdict and the destination, digest, encode, stream.
ExportOutcome exportBank(const ReaSamplerSession& session, const ExportRequest& req);
} // namespace reasampler