From 82d361a72faef97b3776b9ebc239aef13b607634 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 12:33:17 +0000 Subject: [PATCH 01/17] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/link-cli/issues/94 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..51813cb --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-20T12:33:17.110Z for PR creation at branch issue-94-c873317dc78c for issue https://github.com/link-foundation/link-cli/issues/94 \ No newline at end of file From 0eeab48f9ad56c1be5cc95a9bbfa5a002975b75b Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 12:42:46 +0000 Subject: [PATCH 02/17] docs(issue-94): add case study for transactions and version-control layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiles issue-94 evidence under docs/case-studies/issue-94/ as the issue requires before any code changes: raw issue/PR snapshots, upstream UInt64LinksTransactionsLayer C# and C++ references, a 23-requirement decomposition (transactions, version control, cross-cutting), and a step-by-step solution plan that maps to the existing decorator stack (SimpleLinks → NamedTypes → PinnedTypes → PersistentTransformation) and explains how to add the new optional transactions + version-control decorators on top in both C# and Rust. Links the case study from the root README. --- README.md | 1 + docs/case-studies/issue-94/README.md | 549 +++++++++++++ .../github-data/issue-94-comments.json | 1 + .../github-data/issue-94-timeline.json | 1 + .../issue-94/github-data/issue-94.json | 1 + .../issue-94/github-data/pr-95.json | 1 + .../UInt64LinksTransactionsLayer.cs | 748 ++++++++++++++++++ .../references/UInt64LinksTransactionsLayer.h | 272 +++++++ 8 files changed, 1574 insertions(+) create mode 100644 docs/case-studies/issue-94/README.md create mode 100644 docs/case-studies/issue-94/github-data/issue-94-comments.json create mode 100644 docs/case-studies/issue-94/github-data/issue-94-timeline.json create mode 100644 docs/case-studies/issue-94/github-data/issue-94.json create mode 100644 docs/case-studies/issue-94/github-data/pr-95.json create mode 100644 docs/case-studies/issue-94/references/UInt64LinksTransactionsLayer.cs create mode 100644 docs/case-studies/issue-94/references/UInt64LinksTransactionsLayer.h diff --git a/README.md b/README.md index c811bac..6ad5978 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ package built from `doublets-rs`. - [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md): deeper explanation of query processing, references, import/export, triggers, and the WebAssembly workbench. - [docs/case-studies/issue-71/README.md](docs/case-studies/issue-71/README.md): evidence and analysis behind the original documentation refresh. - [docs/case-studies/issue-92/README.md](docs/case-studies/issue-92/README.md): evidence and analysis behind the dual CLI + library packaging and unified API documentation site. +- [docs/case-studies/issue-94/README.md](docs/case-studies/issue-94/README.md): evidence and analysis for the optional transactions and version-control layers. ### API references diff --git a/docs/case-studies/issue-94/README.md b/docs/case-studies/issue-94/README.md new file mode 100644 index 0000000..566dd24 --- /dev/null +++ b/docs/case-studies/issue-94/README.md @@ -0,0 +1,549 @@ +# Issue 94 Case Study: Optional Transactions and Version Control Layers + +Issue: + +Prepared PR: [#95](https://github.com/link-foundation/link-cli/pull/95) + +> Scope of this case study: this folder captures evidence, restated +> requirements, prior-art analysis, and a multi-phase implementation plan +> for shipping an *optional* transactions decorator and an *optional* +> version-control decorator in both the C# and Rust implementations of +> `link-cli` (CLI + library). The case study is the deliverable for the +> first half of the issue. The actual code implementation is split into +> follow-up engineering work tracked from this same PR, because the issue +> body explicitly asks to first *"collect data related about the issue to +> this repository, make sure we compile that data to +> `./docs/case-studies/issue-{id}` folder, and use it to do deep case study +> analysis"* before the code lands. + +## 1. Issue summary + +The issue requests two new, *optional*, *composable* decorator layers that +sit on top of the existing links storage (the same storage that the named +links decorator, pinned types decorator, and persistent transformation +decorator already wrap): + +1. **Transactions layer** — record each write as a reversible *transition*, + support `commit`/`rollback`, infinite or chunked or size-limited log + files, both *sync* and *async* commit modes, and persist the log as a + doublets store so that the log itself is a links database (decorator on + top of *at least* two underlying stores: one for data, one for the + log/transitions). +2. **Version-control layer** — sit on top of the transactions layer to + provide *time travel* to any point covered by the log, plus *branching* + that creates a new transitions file starting from a point in time of an + existing branch. + +The work must be delivered in both **C# and Rust**, both as **CLI flags** +and as **public library APIs**, and must compose with the existing +named-types / pinned-types / persistent-transformation stack the same way +the existing decorators compose. + +The issue specifically points to +[`linksplatform/Data.Doublets/.../UInt64LinksTransactionsLayer.cs`](https://github.com/linksplatform/Data.Doublets/blob/main/csharp/Platform.Data.Doublets/UInt64LinksTransactionsLayer.cs) +as a starting reference, while noting that it is *not finished* and we +"should do much better." + +## 2. Restated requirements + +Broken down into discrete, individually-testable requirements so that the +implementation pull request can check each one off: + +### Transactions (R1–R10) + +| ID | Requirement | +|-----|-------------| +| R1 | Each link operation (Create, Update, Delete) is recorded as a *reversible transition* with enough information to recompute both the *before* and *after* state. | +| R2 | A `BeginTransaction()` API returns a `Transaction` handle that supports `Commit()`, `Rollback()`, and `Dispose()` (auto-rollback if dropped without commit). | +| R3 | A rolled-back transaction reverts every recorded transition in reverse order (delete-of-create → delete, create-of-delete → recreate-with-same-id, update → restore previous values), restoring identical state. | +| R4 | The transactions layer is implemented as a *decorator* over the existing `ILinks` / `LinkStorage` surface, with the same public methods, so it composes with `NamedTypesDecorator`, `PinnedTypesDecorator`, and `PersistentTransformationDecorator`. | +| R5 | The transitions log itself is a *doublets store*, not a bespoke binary file (the log is "also doublets storage"); the layer therefore takes two underlying links sources at construction time — one for data, one for transitions. | +| R6 | Three log retention strategies are supported: **infinite** (default), **chunked** (archive older slices into rotating files), and **size-limited** (drop the oldest applied slice after verifying it was flushed to the data store). | +| R7 | A size-limited log must double-check that every transition it is about to discard has already been *applied* to the data store before deleting it from the log, to avoid losing un-applied work. | +| R8 | Two commit modes are supported: **sync** (a `Commit()` returns only once every transition is applied to the data store) and **async** (a `Commit()` returns as soon as the transitions are durably written to the log; application to the data store happens lazily). | +| R9 | The transactions layer is *optional* — existing CLI invocations and library users that do not opt in see no behavior change. | +| R10 | The transactions layer is recoverable: on startup it detects an incomplete shutdown (last-committed marker != last-written marker) and either replays/rolls-back to a consistent state or refuses to open with a clear diagnostic (matching `Data.Doublets`' current "Database is damaged" behavior, but with a documented recovery path). | + +### Version control (R11–R17) + +| ID | Requirement | +|-----|-------------| +| R11 | A `VersionControlDecorator` sits on top of the transactions layer and exposes the same `ILinks` / `LinkStorage` surface plus VC-specific operations. | +| R12 | `Checkout(point)` *time-travels* the data store to the state at a given transition (by id, by timestamp, or by a named tag), by replaying or rewinding transitions from the current head. | +| R13 | `Branch(name, from?)` creates a new branch starting from the specified point (or the current head). Each branch is backed by its own transitions file that starts after the parent's branch-point. | +| R14 | `ListBranches()` / `CurrentBranch()` / `SwitchBranch(name)` let the caller enumerate and switch between branches. | +| R15 | `Tag(point, name)` and `ListTags()` create human-friendly references to specific points in the history (analogous to git tags). | +| R16 | The version-control layer composes correctly with the transactions layer above it (so write operations during version control time-travel are recorded back into the appropriate branch's log). | +| R17 | The version-control layer is *optional* — existing CLI invocations and library users that do not opt in see no behavior change. | + +### Cross-cutting (R18–R23) + +| ID | Requirement | +|-----|-------------| +| R18 | Both layers are implemented in **C#** and in **Rust** with feature parity (subject to the same documented C# / Rust parity rules already in `docs/REQUIREMENTS.md`). | +| R19 | Both layers are exposed via **CLI flags** (in `clink`) and via **public library APIs** (`Foundation.Data.Doublets.Cli` NuGet and the `link_cli` crate). | +| R20 | The implementation includes unit tests covering commit/rollback, sync/async modes, log retention strategies, recovery, time travel, branching, and composition with the existing decorators. | +| R21 | The implementation includes documentation (in `docs/` and in the CLI help text) explaining the model. | +| R22 | Case-study data is compiled to `./docs/case-studies/issue-94/` (this folder), with extracted issue/PR data, references to prior art, and an enumeration of requirements + proposed solutions. *(This requirement is satisfied by this README.)* | +| R23 | Everything is delivered in a single pull request (#95), incrementally committed so partial work is preserved. | + +## 3. Evidence captured in this folder + +``` +docs/case-studies/issue-94/ +├── README.md # This document. +├── github-data/ +│ ├── issue-94.json # Raw issue payload at investigation time. +│ ├── issue-94-comments.json # Comments at investigation time (empty). +│ ├── issue-94-timeline.json # Issue timeline (labels, assignments). +│ └── pr-95.json # PR snapshot. +└── references/ + ├── UInt64LinksTransactionsLayer.cs # Upstream C# reference cited by the issue. + └── UInt64LinksTransactionsLayer.h # Upstream C++ counterpart for cross-checking. +``` + +The two reference files were copied verbatim from +[linksplatform/Data.Doublets@main](https://github.com/linksplatform/Data.Doublets/tree/main/csharp/Platform.Data.Doublets) +so the case study remains analyzable even if the upstream files move or +change. They are *evidence*, not vendored dependencies — the link-cli code +will not import them. + +## 4. Prior art in this repository + +`link-cli` already ships several composable decorators that follow the +exact pattern the new layers must follow. They are the structural template +for the transactions / version-control implementations: + +| Existing decorator | File | Role | +|--------------------|------|------| +| `SimpleLinksDecorator` | [csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs](../../../csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs) | Bootstraps the primary links store plus a sidecar names store. | +| `NamedTypesDecorator` | [csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs](../../../csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs) | Adds named lookup on top of links. | +| `PinnedTypesDecorator` | [csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypesDecorator.cs](../../../csharp/Foundation.Data.Doublets.Cli.Library/PinnedTypesDecorator.cs) | Maintains "pinned" type ids. | +| `PersistentTransformationDecorator` | [csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs](../../../csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs) | Stores triggers in a sidecar links store and applies them after writes. | +| Rust `NamedTypesDecorator` | [rust/src/named_types.rs](../../../rust/src/named_types.rs) | Rust counterpart of `NamedTypesDecorator`. | +| Rust `PinnedTypesDecorator` | [rust/src/pinned_types.rs](../../../rust/src/pinned_types.rs) | Rust counterpart of `PinnedTypesDecorator`. | + +All four existing C# decorators inherit from +`Platform.Data.Doublets.Decorators.LinksDecoratorBase` (or, for +disposable / file-backed flavors, `LinksDisposableDecoratorBase`). +The upstream `UInt64LinksTransactionsLayer` also inherits +`LinksDisposableDecoratorBase` — the same base — so the C# layer +already has a well-defined place in the existing composition stack. + +On the Rust side `link-cli` does not yet have an explicit decorator +trait; storage is centered on `LinkStorage` plus `NamedLinks` / +`PinnedTypes` types. Adding a transactions layer to Rust therefore also +requires *introducing* a small "links decorator" indirection in `rust/src` +so the new layer can be stacked the same way it is in C#. This is called +out as a design choice in §6. + +## 5. Prior art and online research + +### 5.1 The cited reference: `UInt64LinksTransactionsLayer` + +[`UInt64LinksTransactionsLayer.cs`](references/UInt64LinksTransactionsLayer.cs) +already demonstrates several pieces of the requested design: + +- a `Transition` value-type that carries a transaction id, a `Before` + link, an `After` link, and a `Timestamp`; +- a `Transaction` nested type with `IsCommitted`, `IsReverted`, `Commit`, + and `Dispose` (auto-revert on dispose if not committed); +- a background `TransitionsPusher` task that writes queued transitions to + a file-backed log every `DefaultPushDelay` (≈ 100 ms); +- a first-line "last committed transition" marker on the log file used at + startup to detect un-clean shutdowns; +- `Create` / `Update` / `Delete` overrides that wrap the inner links store + and enqueue a `Transition` in the same write-handler callback the + underlying store already exposes. + +What it is *missing* — and which this case study calls out as +"do much better than the reference": + +- **Nested transactions are explicitly thrown out**: the constructor of + `Transaction` throws `NotSupportedException("Nested transactions not + supported.")` when there is already a current transaction. The issue + does not mandate nested transactions, but the reference's design has no + story for them at all. +- **Async vs. sync commit mode is hard-coded async**: every commit + enqueues onto the layer's `_transitions` queue, which the background + pusher writes "in a while loop with Thread.Sleep(100 ms)". There is no + way to ask `Commit()` to flush synchronously before returning. +- **The log is a binary file, not a links store**: the reference stores + `Transition` structs straight into a file via `Platform.IO.FileHelpers`. + The issue is explicit that the transitions store should itself be a + doublets store, because that is the only way to compose it with + decorators (named transitions, pinned transitions, time-travel views). +- **No log-retention strategy**: the file grows without bound. There is no + chunking, no size limit, no "delete only if applied" check. +- **Auto-recovery is documented as not supported**: the constructor + throws `NotSupportedException("Database is damaged, autorecovery is not + supported yet.")` if the first/last markers don't match. +- **No version-control concept**: the reference has no notion of branches + or tags; there is no `Checkout`, no `Branch`, no `Tag`. The issue is + asking us to add an entirely new VC layer on top. + +### 5.2 External research (theory and patterns) + +The proposed design is informed by well-documented database and +versioning theory. Each citation here is referenced again in §6 next to +the specific decision it supports. + +| Topic | Source | Relevance | +|-------|--------|-----------| +| Write-ahead logging (WAL) — log records, undo+redo info, recovery | [Wikipedia: Write-ahead logging](https://en.wikipedia.org/wiki/Write-ahead_logging) | Justifies storing both *before* and *after* in each transition. WAL is the textbook pattern for atomic, recoverable writes. | +| SQLite WAL — append-only log, COMMIT = mark + flush, rollback = don't append commit | [sqlite.org/wal.html](https://www.sqlite.org/wal.html) | Justifies sync vs. async commit and shows how *checkpointing* (transferring log to data store) is the deferred-application primitive. | +| PostgreSQL WAL — point-in-time recovery (PITR) | [postgresql.org/docs/.../wal-intro.html](https://www.postgresql.org/docs/current/wal-intro.html) | Justifies that *time-travel* is a special case of "replay the log up to a point", validating R12. | +| Event sourcing — events are the truth, state is derived, snapshots, replay | [martinfowler.com/eaaDev/EventSourcing.html](https://martinfowler.com/eaaDev/EventSourcing.html) | The conceptual basis for storing transitions as the authoritative timeline and deriving any historical state by replay. | +| Git — immutable objects, branches are pointers, checkout for time travel | [git-scm.com/docs/gitcore-tutorial](https://git-scm.com/docs/gitcore-tutorial) | Branching model for R13: each branch is a pointer + its own transitions slice. | +| Dolt — SQL DB with Git-style branching, diff, merge | [docs.dolthub.com/concepts/dolt](https://docs.dolthub.com/concepts/dolt) | Existence proof that Git-style version control over structured data is a workable product surface; informs the CLI naming (`branch`, `checkout`, `tag`). | +| MVCC — multiple versions, snapshot isolation, garbage collection | [Wikipedia: MVCC](https://en.wikipedia.org/wiki/Multiversion_concurrency_control) | Informs the "limited log" retention strategy: an applied transition is "garbage" once every consumer has caught up. | +| `sled` (Rust) — log-structured, atomic batches, transactions | [docs.rs/sled](https://docs.rs/sled/latest/sled/) | Existence proof that a thread-safe, log-structured store with batches and snapshots is achievable in pure Rust if we end up wanting to switch the Rust transitions log to a third-party storage backend. | + +### 5.3 Existing components in the link-cli dependency tree + +These are libraries already on the dependency surface and can be reused +rather than re-implemented: + +- **`Platform.Data.Doublets.Decorators.LinksDecoratorBase`** and + **`LinksDisposableDecoratorBase`** — the abstract base for any + links decorator. `PersistentTransformationDecorator`, + `NamedTypesDecorator`, and the upstream `UInt64LinksTransactionsLayer` + all derive from one of these. +- **`Platform.Timestamps.UniqueTimestampFactory`** — already used by the + upstream layer to produce monotonic timestamps for transitions. +- **`Platform.IO.FileHelpers`** — append-only file helpers already used by + the upstream reference. We will *replace* the binary file with a + doublets store (per R5), but if we ever need a side-channel marker file, + this is the established way. +- **`doublets` crate** (Rust) — already a dependency, provides the same + storage primitives in Rust. We can stack a new decorator over it. + +## 6. Solution plan + +The plan is split into six self-contained steps. Each step is committable +and reviewable independently and is sized to fit a single follow-up +commit on PR #95. + +### Step S1 — Establish the case study (this commit) + +Create `docs/case-studies/issue-94/` with this README, evidence files, +and references. **Done in this commit.** Satisfies R22. + +### Step S2 — Define the shared `ITransactionsLinks` API surface + +Add the API surface in both languages without an implementation. This +locks the design down and lets the test plan be written before the actual +storage code. + +In C# (`csharp/Foundation.Data.Doublets.Cli.Library/Transactions/`): + +```csharp +public interface ITransactionsLinks : ILinks +{ + ITransaction BeginTransaction(); // R2 + Task BeginTransactionAsync(CancellationToken ct = default); + IReadOnlyList> Log { get; } + LogRetentionPolicy RetentionPolicy { get; } + CommitMode CommitMode { get; set; } // Sync vs. Async (R8) +} + +public interface ITransaction : IDisposable +{ + Guid Id { get; } + DateTimeOffset StartedAt { get; } + bool IsCommitted { get; } + bool IsRolledBack { get; } + void Commit(); // R2 + void Rollback(); // R2 + Task CommitAsync(CancellationToken ct = default); +} + +public readonly record struct Transition( + Guid TransactionId, + long Sequence, + DateTimeOffset Timestamp, + Link Before, + Link After); + +public enum CommitMode { Sync, Async } // R8 + +public abstract record LogRetentionPolicy +{ + public sealed record Infinite() : LogRetentionPolicy; // R6 + public sealed record Chunked(long ChunkSize, string ArchiveDirectory) : LogRetentionPolicy; // R6 + public sealed record Sized(long MaxBytes) : LogRetentionPolicy; // R6 + R7 +} +``` + +In Rust (`rust/src/transactions/mod.rs`): + +```rust +pub trait TransactionsLinks: LinksStorage { + type Transaction: Transaction; + fn begin_transaction(&self) -> Self::Transaction; + fn log(&self) -> &dyn LogReader; + fn commit_mode(&self) -> CommitMode; + fn set_commit_mode(&mut self, mode: CommitMode); +} + +pub trait Transaction: Drop { + fn id(&self) -> u128; + fn started_at(&self) -> SystemTime; + fn is_committed(&self) -> bool; + fn is_rolled_back(&self) -> bool; + fn commit(self) -> Result<()>; + fn rollback(self) -> Result<()>; +} + +pub enum CommitMode { Sync, Async } +pub enum LogRetentionPolicy { + Infinite, + Chunked { chunk_size: u64, archive_dir: PathBuf }, + Sized { max_bytes: u64 }, +} +``` + +The `LinksStorage` trait extracted on the Rust side is the new +indirection mentioned in §4. It is a minimal trait covering `create`, +`update`, `delete`, `each`, `get_link`, and `exists` — exactly the +methods that `LinkStorage` already implements. Existing call sites +re-route through the trait without behavior change. + +Satisfies the *API* parts of R1, R2, R4, R6, R8, R18, R19. + +### Step S3 — Implement transactions on top of a *doublets* log + +In each language, implement the API by storing transitions as links in a +*second* doublets store. The store layout encodes one transition as a +small graph: + +```text +( :transaction-root) +( :sequence-of ) +( :timestamp ) +( :before-source ) +( :before-target ) +( :after-source ) +( :after-target ) +``` + +The keys `:transaction-root`, `:sequence-of`, `:timestamp`, +`:before-source`, `:before-target`, `:after-source`, `:after-target` are +*named points* exactly the way `PersistentTransformationDecorator` +already represents `Type`, `Trigger`, `Once`, `Always`, `Condition`, +`Substitution` in the trigger sidecar (see +`csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs:242-258`). +This means the transitions log is itself queryable through the existing +LiNo query processor — which directly supports the issue's "log itself is +also doublets storage" requirement (R5). + +The C# implementation derives from `LinksDisposableDecoratorBase` +exactly like `UInt64LinksTransactionsLayer` does, and the Rust +implementation implements the new `TransactionsLinks` trait by wrapping +two `LinkStorage` instances (one for data, one for the log). + +Implementation notes: + +- `Commit()` (sync mode, R8) walks the in-memory transaction's transition + list and synchronously writes each transition record into the log + store, then *applies* it to the data store, then flushes both. +- `Commit()` (async mode, R8) writes the transition records into the log + store synchronously, then enqueues the data-store application onto a + background task. The transaction is "committed" as soon as the log is + durable, mirroring SQLite's WAL commit semantics. +- `Rollback()` (R3) iterates the transitions in reverse and inverts each + operation against the *data* store (create → delete by id, delete → + recreate with prior source/target, update → update back). The log + records the rollback as additional transitions tagged with the parent + transaction's id so the history remains complete and reproducible. +- `Dispose()` on `Transaction` calls `Rollback()` if `IsCommitted == + false && IsRolledBack == false` (mirrors the upstream reference). +- The `Sized` retention policy (R7) only drops the oldest *applied* + chunk: a transition is "applied" once its data-store write has + succeeded. In async mode the "applied" set is exactly the prefix the + background applier has caught up to. + +Satisfies R1, R2, R3, R4, R5, R6, R7, R8. + +### Step S4 — Recovery, durability, and async backpressure + +On startup the transactions layer scans the log, finds the last fully +applied transition (the one whose data-store side-effect is observable), +and either: + +- replays remaining log entries forward into the data store (async mode + catch-up), or +- rolls back any log entries that belong to an *un-committed* transaction + (transactions whose final `:commit` marker is missing). + +In async mode the background applier signals backpressure to the writer +when the log gets too far ahead of the data store, by transparently +falling back to sync commits until the queue has caught up. This is the +canonical WAL recovery + checkpoint pattern from the PostgreSQL and +SQLite write-ups cited in §5.2. + +Satisfies R10. + +### Step S5 — Implement the `VersionControlDecorator` + +On top of the transactions layer, add a separate decorator that adds: + +- a `branches` named-points subgraph in the log store + (`:branch `, `:branch-head `, `:branch-parent + `, `:branch-parent-point `); +- a `tags` named-points subgraph (`:tag `, `:tag-point + `); +- a `Checkout(point)` method (R12) that walks the data store back to the + requested point by inverting transitions newer than the point and + re-applying them when checking out a forward point; +- a `Branch(name, from?)` method (R13) that creates a new branch row in + the log and *forks* the underlying log file into a new sidecar + (`..transitions.links`) so further writes on that + branch don't pollute the parent's log; +- a `SwitchBranch(name)` method (R14) that performs a `Checkout(point)` + to the branch's head and points all subsequent writes at the branch's + log; +- a `Tag(point, name)` / `ListTags()` API (R15); +- composition guarantees with the inner transactions layer (R16): every + write during VC time-travel is recorded back into the *current + branch's* log. + +The decorator inherits from `LinksDecoratorBase` in C# and +implements the same `LinksStorage` trait extracted in S2 in Rust, so it +can in turn be wrapped by `NamedTypesDecorator`, +`PinnedTypesDecorator`, and `PersistentTransformationDecorator` if a user +opts in. The order of composition is documented as: + +```text +PersistentTransformationDecorator +└── PinnedTypesDecorator + └── NamedTypesDecorator + └── VersionControlDecorator (optional, R11) + └── TransactionsDecorator (optional, R1-R10) + └── UnitedMemoryLinks (data store) + + + ┌── named transitions store (doublets) (R5) +``` + +Satisfies R11, R12, R13, R14, R15, R16. + +### Step S6 — CLI flags and library examples + +CLI (`clink`) additions in both implementations: + +- `--transactions ` — enable the transactions layer; `` is + the doublets log store (default: `.transitions.links`). +- `--commit-mode sync|async` — choose sync or async commits (R8). + Defaults to `sync` for safety. +- `--retention infinite|sized:|chunked::` — set the + retention policy (R6, R7). +- `--vc` — enable the version-control decorator (R11). +- `--branch ` — switch to a branch (creating it if `--branch-from + ` is also passed) (R13, R14). +- `--checkout ` — time-travel the data store to a specific + transition id, timestamp, or tag (R12). +- `--tag =` — create a tag (R15). +- `--list-branches`, `--list-tags`, `--log` — read-only inspection + commands. + +Library examples added under `examples/`: + +- `examples/transactions-csharp/` — minimal C# program that opens a links + store with the transactions decorator, begins a transaction, performs + a few CRUD operations, and either commits or rolls back. +- `examples/transactions-rust/` — Rust equivalent. +- `examples/version-control-csharp/` and + `examples/version-control-rust/` — branch, tag, and checkout demos. + +Satisfies R9, R17, R18, R19, R21. + +### Step S7 — Tests + +For each language: + +- Unit tests for commit, rollback, dispose-without-commit, nested + transactions (asserting current "not supported" behavior with a clear + error), and a stress test that performs random CRUD with random + commit/rollback decisions and asserts that the data store ends in the + same state as a reference Hash-based replay. +- Recovery tests: kill mid-write (via injected fault), reopen, assert the + layer recovers to the last fully-committed state. +- Retention tests for `Sized` and `Chunked` policies, asserting that no + un-applied transition is ever dropped (R7). +- Branch / tag / checkout tests that build a small history with two + branches and assert that switching back and forth produces byte-identical + database snapshots at every named point. +- Composition tests that stack `NamedTypesDecorator` / + `PinnedTypesDecorator` / `PersistentTransformationDecorator` on top of + the new layers and re-run the existing CRUD test suite, asserting no + regressions. + +Satisfies R20. + +### Step S8 — Documentation + +- Update `docs/REQUIREMENTS.md` to mark the optional transactions and + version-control entries as *implemented*. +- Update `docs/ARCHITECTURE.md` with the new composition stack + illustrated in S5. +- Update `docs/HOW-IT-WORKS.md` with a "Time travel and branching" + section that walks through a small example. +- Update both `csharp/README.md` and `rust/README.md` with the new CLI + flags and library APIs. +- Cross-link this case study from the documentation index. + +Satisfies R21, R22. + +## 7. Risks and trade-offs + +| Risk | Mitigation | +|------|------------| +| Writing every transition into a *links* store, rather than a flat file, is slower than the upstream reference's `FileStream.Write(transition)`. | Acceptable for correctness; the issue explicitly asks for this. The log store can use the same `UnitedMemoryLinks` backend the main store already uses, so the overhead is well-understood. Async commit mode preserves the latency benefit of the flat-file approach for write-heavy workloads. | +| Branching requires forking the log file. If two branches diverge for a long time, the on-disk footprint is roughly `O(branches × transitions)`. | Documented limitation. Mirrors git's on-disk model. The `Chunked` retention policy can rotate inactive branches into archived chunks. | +| The Rust side currently has no explicit `LinksDecorator` trait, so adding the transactions layer requires extracting one. | The extraction is mechanical — the existing `LinkStorage`, `NamedLinks`, and `PinnedTypes` types already implement the same effective surface. We refactor in a single commit so the trait extraction is reviewable on its own. | +| The upstream reference rejects nested transactions. The issue does not ask for nesting, but a future user might. | Out of scope for this PR; we throw a clear `NotSupportedException` (C#) / `Err(TransactionsError::NestedNotSupported)` (Rust) and document it. | +| Time-travel via checkout has to *invert* all newer transitions when going back in time. If the log is very long this is O(n). | Documented as O(n); a `Snapshot(point)` API that materializes a checkpoint can be added later (event-sourcing style) if the linear cost becomes a problem in practice. | +| Auto-recovery on a crashed log is not in scope yet; the upstream reference also lacks it. | This case study calls it out (R10) and S4 provides a *correct* recovery story by validating commit markers on startup, but a full crash-stress test suite is deferred to a follow-up. | + +## 8. Existing libraries we considered + +| Library | Decision | +|---------|----------| +| `sled` (Rust) | **No** — full re-platform of storage, not a decorator. We borrow design ideas (`Tree::transaction`, atomic batches) but not the dependency. | +| `rocksdb` / `lmdb` | **No** — same reason. | +| `pijul` / `git2` (Rust) | **No** — version-control libraries but with their own data model. Our VC layer is over *links*, not files. | +| `LiteDB` (.NET) | **No** — alternative storage, not a decorator pattern. | +| `Platform.IO.FileHelpers` (already a dependency) | **Yes** — for side-channel markers if needed. | +| `Platform.Timestamps.UniqueTimestampFactory` (already a dependency) | **Yes** — direct reuse for monotonic timestamps in transitions. | +| `Platform.Data.Doublets.Decorators.LinksDecoratorBase` (already a dependency) | **Yes** — direct reuse as the base class for the new C# decorators. | +| `doublets` crate (already a dependency) | **Yes** — direct reuse for the Rust transitions store. | + +## 9. Verification plan + +- `dotnet build csharp/Foundation.Data.Doublets.Cli.sln -c Release` + succeeds with the new project sources. +- `dotnet test csharp/Foundation.Data.Doublets.Cli.sln -c Release` passes + all existing tests *and* the new transactions / version-control tests. +- `cargo build --manifest-path rust/Cargo.toml --release` succeeds. +- `cargo test --manifest-path rust/Cargo.toml` passes all existing tests + *and* the new ones. +- `cargo fmt --check` and `cargo clippy --all-targets --all-features + -- -D warnings` keep passing. +- The CI on PR #95 keeps passing across `ubuntu-latest`, `macos-latest`, + and `windows-latest`. +- The new examples under `examples/transactions-*` and + `examples/version-control-*` each run end-to-end via `dotnet run` / + `cargo run` and demonstrate a committed transaction, a rolled-back + transaction, and a checkout/branch round-trip. + +## 10. Delivery plan on PR #95 + +This case study is the first commit on PR #95. The follow-up commits will +each correspond to one of the steps S2–S8 above, in order. The PR will +remain *draft* until S8 lands; only then will it be marked ready for +review. + +Per the issue: *"Please plan and execute everything in a single pull +request, you have unlimited time and context, as context auto-compacts +and you can continue indefinitely, until it is each and every requirement +fully addressed, and everything is totally done."* diff --git a/docs/case-studies/issue-94/github-data/issue-94-comments.json b/docs/case-studies/issue-94/github-data/issue-94-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/case-studies/issue-94/github-data/issue-94-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/case-studies/issue-94/github-data/issue-94-timeline.json b/docs/case-studies/issue-94/github-data/issue-94-timeline.json new file mode 100644 index 0000000..a0840eb --- /dev/null +++ b/docs/case-studies/issue-94/github-data/issue-94-timeline.json @@ -0,0 +1 @@ +[{"id":25757098085,"node_id":"LE_lADONXCAbs8AAAABC2mIp88AAAAF_z4gZQ","url":"https://api.github.com/repos/link-foundation/link-cli/issues/events/25757098085","actor":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"event":"labeled","commit_id":null,"commit_url":null,"created_at":"2026-05-20T12:32:31Z","label":{"name":"documentation","color":"0075ca"},"performed_via_github_app":null},{"id":25757098223,"node_id":"LE_lADONXCAbs8AAAABC2mIp88AAAAF_z4g7w","url":"https://api.github.com/repos/link-foundation/link-cli/issues/events/25757098223","actor":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"event":"labeled","commit_id":null,"commit_url":null,"created_at":"2026-05-20T12:32:31Z","label":{"name":"enhancement","color":"a2eeef"},"performed_via_github_app":null},{"id":25757098727,"node_id":"ITAE_lADONXCAbs8AAAABC2mIp88AAAAF_z4i5w","url":"https://api.github.com/repos/link-foundation/link-cli/issues/events/25757098727","actor":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"event":"issue_type_added","commit_id":null,"commit_url":null,"created_at":"2026-05-20T12:32:32Z","performed_via_github_app":null},{"id":25757106771,"node_id":"RTE_lADONXCAbs8AAAABC2mIp88AAAAF_z5CUw","url":"https://api.github.com/repos/link-foundation/link-cli/issues/events/25757106771","actor":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"event":"renamed","commit_id":null,"commit_url":null,"created_at":"2026-05-20T12:32:42Z","rename":{"from":"Optional transactions layer","to":"Optional transactions and version control layers"},"performed_via_github_app":null},{"actor":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"created_at":"2026-05-20T12:33:26Z","updated_at":"2026-05-20T12:33:26Z","source":{"type":"issue","issue":{"url":"https://api.github.com/repos/link-foundation/link-cli/issues/95","repository_url":"https://api.github.com/repos/link-foundation/link-cli","labels_url":"https://api.github.com/repos/link-foundation/link-cli/issues/95/labels{/name}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/issues/95/comments","events_url":"https://api.github.com/repos/link-foundation/link-cli/issues/95/events","html_url":"https://github.com/link-foundation/link-cli/pull/95","id":4486440440,"node_id":"PR_kwDONXCAbs7dhmSJ","number":95,"title":"[WIP] Optional transactions and version control layers","user":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"open","locked":false,"assignees":[{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false}],"milestone":null,"comments":0,"created_at":"2026-05-20T12:33:25Z","updated_at":"2026-05-20T12:33:27Z","closed_at":null,"assignee":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"author_association":"MEMBER","issue_field_values":[],"type":null,"active_lock_reason":null,"draft":true,"repository":{"id":896565358,"node_id":"R_kgDONXCAbg","name":"link-cli","full_name":"link-foundation/link-cli","private":false,"owner":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/link-foundation/link-cli","description":"A CLI tool to manipulate links.","fork":false,"url":"https://api.github.com/repos/link-foundation/link-cli","forks_url":"https://api.github.com/repos/link-foundation/link-cli/forks","keys_url":"https://api.github.com/repos/link-foundation/link-cli/keys{/key_id}","collaborators_url":"https://api.github.com/repos/link-foundation/link-cli/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/link-foundation/link-cli/teams","hooks_url":"https://api.github.com/repos/link-foundation/link-cli/hooks","issue_events_url":"https://api.github.com/repos/link-foundation/link-cli/issues/events{/number}","events_url":"https://api.github.com/repos/link-foundation/link-cli/events","assignees_url":"https://api.github.com/repos/link-foundation/link-cli/assignees{/user}","branches_url":"https://api.github.com/repos/link-foundation/link-cli/branches{/branch}","tags_url":"https://api.github.com/repos/link-foundation/link-cli/tags","blobs_url":"https://api.github.com/repos/link-foundation/link-cli/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/link-foundation/link-cli/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/link-foundation/link-cli/git/refs{/sha}","trees_url":"https://api.github.com/repos/link-foundation/link-cli/git/trees{/sha}","statuses_url":"https://api.github.com/repos/link-foundation/link-cli/statuses/{sha}","languages_url":"https://api.github.com/repos/link-foundation/link-cli/languages","stargazers_url":"https://api.github.com/repos/link-foundation/link-cli/stargazers","contributors_url":"https://api.github.com/repos/link-foundation/link-cli/contributors","subscribers_url":"https://api.github.com/repos/link-foundation/link-cli/subscribers","subscription_url":"https://api.github.com/repos/link-foundation/link-cli/subscription","commits_url":"https://api.github.com/repos/link-foundation/link-cli/commits{/sha}","git_commits_url":"https://api.github.com/repos/link-foundation/link-cli/git/commits{/sha}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/comments{/number}","issue_comment_url":"https://api.github.com/repos/link-foundation/link-cli/issues/comments{/number}","contents_url":"https://api.github.com/repos/link-foundation/link-cli/contents/{+path}","compare_url":"https://api.github.com/repos/link-foundation/link-cli/compare/{base}...{head}","merges_url":"https://api.github.com/repos/link-foundation/link-cli/merges","archive_url":"https://api.github.com/repos/link-foundation/link-cli/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/link-foundation/link-cli/downloads","issues_url":"https://api.github.com/repos/link-foundation/link-cli/issues{/number}","pulls_url":"https://api.github.com/repos/link-foundation/link-cli/pulls{/number}","milestones_url":"https://api.github.com/repos/link-foundation/link-cli/milestones{/number}","notifications_url":"https://api.github.com/repos/link-foundation/link-cli/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/link-foundation/link-cli/labels{/name}","releases_url":"https://api.github.com/repos/link-foundation/link-cli/releases{/id}","deployments_url":"https://api.github.com/repos/link-foundation/link-cli/deployments","created_at":"2024-11-30T17:46:38Z","updated_at":"2026-05-15T14:36:46Z","pushed_at":"2026-05-20T12:33:18Z","git_url":"git://github.com/link-foundation/link-cli.git","ssh_url":"git@github.com:link-foundation/link-cli.git","clone_url":"https://github.com/link-foundation/link-cli.git","svn_url":"https://github.com/link-foundation/link-cli","homepage":"https://link-foundation.github.io/link-cli/","size":4636,"stargazers_count":9,"watchers_count":9,"language":"Rust","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":true,"has_discussions":false,"forks_count":1,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":21,"license":{"key":"unlicense","name":"The Unlicense","spdx_id":"Unlicense","url":"https://api.github.com/licenses/unlicense","node_id":"MDc6TGljZW5zZTE1"},"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"has_pull_requests":true,"pull_request_creation_policy":"all","topics":[],"visibility":"public","forks":1,"open_issues":21,"watchers":9,"default_branch":"main","permissions":{"admin":true,"maintain":true,"push":true,"triage":true,"pull":true}},"pull_request":{"url":"https://api.github.com/repos/link-foundation/link-cli/pulls/95","html_url":"https://github.com/link-foundation/link-cli/pull/95","diff_url":"https://github.com/link-foundation/link-cli/pull/95.diff","patch_url":"https://github.com/link-foundation/link-cli/pull/95.patch","merged_at":null},"body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #94.\n\n### 📋 Issue Reference\nFixes #94\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","reactions":{"url":"https://api.github.com/repos/link-foundation/link-cli/issues/95/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/link-foundation/link-cli/issues/95/timeline","performed_via_github_app":null,"state_reason":null}},"event":"cross-referenced"}] \ No newline at end of file diff --git a/docs/case-studies/issue-94/github-data/issue-94.json b/docs/case-studies/issue-94/github-data/issue-94.json new file mode 100644 index 0000000..0927dcf --- /dev/null +++ b/docs/case-studies/issue-94/github-data/issue-94.json @@ -0,0 +1 @@ +{"url":"https://api.github.com/repos/link-foundation/link-cli/issues/94","repository_url":"https://api.github.com/repos/link-foundation/link-cli","labels_url":"https://api.github.com/repos/link-foundation/link-cli/issues/94/labels{/name}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/issues/94/comments","events_url":"https://api.github.com/repos/link-foundation/link-cli/issues/94/events","html_url":"https://github.com/link-foundation/link-cli/issues/94","id":4486432935,"node_id":"I_kwDONXCAbs8AAAABC2mIpw","number":94,"title":"Optional transactions and version control layers","user":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[{"id":7821083708,"node_id":"LA_kwDONXCAbs8AAAAB0ixEPA","url":"https://api.github.com/repos/link-foundation/link-cli/labels/documentation","name":"documentation","color":"0075ca","default":true,"description":"Improvements or additions to documentation"},{"id":7821083712,"node_id":"LA_kwDONXCAbs8AAAAB0ixEQA","url":"https://api.github.com/repos/link-foundation/link-cli/labels/enhancement","name":"enhancement","color":"a2eeef","default":true,"description":"New feature or request"}],"state":"open","locked":false,"assignees":[],"milestone":null,"comments":0,"created_at":"2026-05-20T12:32:29Z","updated_at":"2026-05-20T12:32:42Z","closed_at":null,"assignee":null,"author_association":"MEMBER","issue_field_values":[],"type":{"id":22969357,"node_id":"IT_kwDOCoAzvc4BXnwN","name":"Feature","description":"A request, idea, or new functionality","color":"blue","created_at":"2024-07-20T19:06:39Z","updated_at":"2024-10-08T23:47:30Z","is_enabled":true},"active_lock_reason":null,"sub_issues_summary":{"total":0,"completed":0,"percent_completed":0},"issue_dependencies_summary":{"blocked_by":0,"total_blocked_by":0,"blocking":0,"total_blocking":0},"body":"Transactions should be recorded in such a way, that each operation in translation is reversible. So if we would like to rollback the transaction we can do so easily.\n\nDo there should be Transactions decorator, that introduces transaction concept, that is possible to commit, rollback and so on.\n\nBy default we should support infinite transactions file, its chunked version for archive purposes, and limited size (which should double check that all transactions actually applied before being deleted from the translactions layer or log.\n\nThat log itself is also doublets storage, so it should be decorator on top of at least two links data stores - one for the data itself (or decorator on top of named links decorator), and one for the transactions and transitions.\n\nYou can see example https://github.com/linksplatform/Data.Doublets/blob/main/csharp/Platform.Data.Doublets/UInt64LinksTransactionsLayer.cs - but it is not finished and we should do much better.\n\nAlso we need to support sync and async transactions, meaning sync is transaction finished only when all changes are applied to main data store, and async means, it is fine to just record to transactions first and application of changes goes as soon as it can asynchronously.\n\nWe also need to add VersionControl decorator on top of Transactions layer, so we can we can time travel to any period in time within saved transactions range, with ability to branch the history, each branch will essentially create separate transactions file, that will start on top of point in time previous transactions file.\n\nTime travel means we rewind some transactions to get actual state of the data at specific point in time.\n\nEverything should be implement in both Rust and C# and available in both CLI and library.\n\nWe need to collect data related about the issue to this repository, make sure we compile that data to `./docs/case-studies/issue-{id}` folder, and use it to do deep case study analysis (also make sure to search online for additional facts and data), list of each and all requirements from the issue, and propose possible solutions and solution plans for each requirement (we should also check known existing components/libraries, that solve similar problem or can help in solutions).\n\nPlease plan and execute everything in a single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done.\n\n","closed_by":null,"reactions":{"url":"https://api.github.com/repos/link-foundation/link-cli/issues/94/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/link-foundation/link-cli/issues/94/timeline","performed_via_github_app":null,"state_reason":null,"pinned_comment":null} \ No newline at end of file diff --git a/docs/case-studies/issue-94/github-data/pr-95.json b/docs/case-studies/issue-94/github-data/pr-95.json new file mode 100644 index 0000000..ca665f5 --- /dev/null +++ b/docs/case-studies/issue-94/github-data/pr-95.json @@ -0,0 +1 @@ +{"url":"https://api.github.com/repos/link-foundation/link-cli/pulls/95","id":3716572297,"node_id":"PR_kwDONXCAbs7dhmSJ","html_url":"https://github.com/link-foundation/link-cli/pull/95","diff_url":"https://github.com/link-foundation/link-cli/pull/95.diff","patch_url":"https://github.com/link-foundation/link-cli/pull/95.patch","issue_url":"https://api.github.com/repos/link-foundation/link-cli/issues/95","number":95,"state":"open","locked":false,"title":"[WIP] Optional transactions and version control layers","user":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #94.\n\n### 📋 Issue Reference\nFixes #94\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","created_at":"2026-05-20T12:33:25Z","updated_at":"2026-05-20T12:33:27Z","closed_at":null,"merged_at":null,"merge_commit_sha":"b70bca182074860b8466641a4834cccad096b477","assignees":[{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false}],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"draft":true,"commits_url":"https://api.github.com/repos/link-foundation/link-cli/pulls/95/commits","review_comments_url":"https://api.github.com/repos/link-foundation/link-cli/pulls/95/comments","review_comment_url":"https://api.github.com/repos/link-foundation/link-cli/pulls/comments{/number}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/issues/95/comments","statuses_url":"https://api.github.com/repos/link-foundation/link-cli/statuses/82d361a72faef97b3776b9ebc239aef13b607634","head":{"label":"link-foundation:issue-94-c873317dc78c","ref":"issue-94-c873317dc78c","sha":"82d361a72faef97b3776b9ebc239aef13b607634","user":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"repo":{"id":896565358,"node_id":"R_kgDONXCAbg","name":"link-cli","full_name":"link-foundation/link-cli","private":false,"owner":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/link-foundation/link-cli","description":"A CLI tool to manipulate links.","fork":false,"url":"https://api.github.com/repos/link-foundation/link-cli","forks_url":"https://api.github.com/repos/link-foundation/link-cli/forks","keys_url":"https://api.github.com/repos/link-foundation/link-cli/keys{/key_id}","collaborators_url":"https://api.github.com/repos/link-foundation/link-cli/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/link-foundation/link-cli/teams","hooks_url":"https://api.github.com/repos/link-foundation/link-cli/hooks","issue_events_url":"https://api.github.com/repos/link-foundation/link-cli/issues/events{/number}","events_url":"https://api.github.com/repos/link-foundation/link-cli/events","assignees_url":"https://api.github.com/repos/link-foundation/link-cli/assignees{/user}","branches_url":"https://api.github.com/repos/link-foundation/link-cli/branches{/branch}","tags_url":"https://api.github.com/repos/link-foundation/link-cli/tags","blobs_url":"https://api.github.com/repos/link-foundation/link-cli/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/link-foundation/link-cli/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/link-foundation/link-cli/git/refs{/sha}","trees_url":"https://api.github.com/repos/link-foundation/link-cli/git/trees{/sha}","statuses_url":"https://api.github.com/repos/link-foundation/link-cli/statuses/{sha}","languages_url":"https://api.github.com/repos/link-foundation/link-cli/languages","stargazers_url":"https://api.github.com/repos/link-foundation/link-cli/stargazers","contributors_url":"https://api.github.com/repos/link-foundation/link-cli/contributors","subscribers_url":"https://api.github.com/repos/link-foundation/link-cli/subscribers","subscription_url":"https://api.github.com/repos/link-foundation/link-cli/subscription","commits_url":"https://api.github.com/repos/link-foundation/link-cli/commits{/sha}","git_commits_url":"https://api.github.com/repos/link-foundation/link-cli/git/commits{/sha}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/comments{/number}","issue_comment_url":"https://api.github.com/repos/link-foundation/link-cli/issues/comments{/number}","contents_url":"https://api.github.com/repos/link-foundation/link-cli/contents/{+path}","compare_url":"https://api.github.com/repos/link-foundation/link-cli/compare/{base}...{head}","merges_url":"https://api.github.com/repos/link-foundation/link-cli/merges","archive_url":"https://api.github.com/repos/link-foundation/link-cli/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/link-foundation/link-cli/downloads","issues_url":"https://api.github.com/repos/link-foundation/link-cli/issues{/number}","pulls_url":"https://api.github.com/repos/link-foundation/link-cli/pulls{/number}","milestones_url":"https://api.github.com/repos/link-foundation/link-cli/milestones{/number}","notifications_url":"https://api.github.com/repos/link-foundation/link-cli/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/link-foundation/link-cli/labels{/name}","releases_url":"https://api.github.com/repos/link-foundation/link-cli/releases{/id}","deployments_url":"https://api.github.com/repos/link-foundation/link-cli/deployments","created_at":"2024-11-30T17:46:38Z","updated_at":"2026-05-15T14:36:46Z","pushed_at":"2026-05-20T12:33:18Z","git_url":"git://github.com/link-foundation/link-cli.git","ssh_url":"git@github.com:link-foundation/link-cli.git","clone_url":"https://github.com/link-foundation/link-cli.git","svn_url":"https://github.com/link-foundation/link-cli","homepage":"https://link-foundation.github.io/link-cli/","size":4636,"stargazers_count":9,"watchers_count":9,"language":"Rust","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":true,"has_discussions":false,"forks_count":1,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":21,"license":{"key":"unlicense","name":"The Unlicense","spdx_id":"Unlicense","url":"https://api.github.com/licenses/unlicense","node_id":"MDc6TGljZW5zZTE1"},"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"has_pull_requests":true,"pull_request_creation_policy":"all","topics":[],"visibility":"public","forks":1,"open_issues":21,"watchers":9,"default_branch":"main"}},"base":{"label":"link-foundation:main","ref":"main","sha":"8c60261b885d2299655a623c060161dadf298235","user":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"repo":{"id":896565358,"node_id":"R_kgDONXCAbg","name":"link-cli","full_name":"link-foundation/link-cli","private":false,"owner":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/link-foundation/link-cli","description":"A CLI tool to manipulate links.","fork":false,"url":"https://api.github.com/repos/link-foundation/link-cli","forks_url":"https://api.github.com/repos/link-foundation/link-cli/forks","keys_url":"https://api.github.com/repos/link-foundation/link-cli/keys{/key_id}","collaborators_url":"https://api.github.com/repos/link-foundation/link-cli/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/link-foundation/link-cli/teams","hooks_url":"https://api.github.com/repos/link-foundation/link-cli/hooks","issue_events_url":"https://api.github.com/repos/link-foundation/link-cli/issues/events{/number}","events_url":"https://api.github.com/repos/link-foundation/link-cli/events","assignees_url":"https://api.github.com/repos/link-foundation/link-cli/assignees{/user}","branches_url":"https://api.github.com/repos/link-foundation/link-cli/branches{/branch}","tags_url":"https://api.github.com/repos/link-foundation/link-cli/tags","blobs_url":"https://api.github.com/repos/link-foundation/link-cli/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/link-foundation/link-cli/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/link-foundation/link-cli/git/refs{/sha}","trees_url":"https://api.github.com/repos/link-foundation/link-cli/git/trees{/sha}","statuses_url":"https://api.github.com/repos/link-foundation/link-cli/statuses/{sha}","languages_url":"https://api.github.com/repos/link-foundation/link-cli/languages","stargazers_url":"https://api.github.com/repos/link-foundation/link-cli/stargazers","contributors_url":"https://api.github.com/repos/link-foundation/link-cli/contributors","subscribers_url":"https://api.github.com/repos/link-foundation/link-cli/subscribers","subscription_url":"https://api.github.com/repos/link-foundation/link-cli/subscription","commits_url":"https://api.github.com/repos/link-foundation/link-cli/commits{/sha}","git_commits_url":"https://api.github.com/repos/link-foundation/link-cli/git/commits{/sha}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/comments{/number}","issue_comment_url":"https://api.github.com/repos/link-foundation/link-cli/issues/comments{/number}","contents_url":"https://api.github.com/repos/link-foundation/link-cli/contents/{+path}","compare_url":"https://api.github.com/repos/link-foundation/link-cli/compare/{base}...{head}","merges_url":"https://api.github.com/repos/link-foundation/link-cli/merges","archive_url":"https://api.github.com/repos/link-foundation/link-cli/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/link-foundation/link-cli/downloads","issues_url":"https://api.github.com/repos/link-foundation/link-cli/issues{/number}","pulls_url":"https://api.github.com/repos/link-foundation/link-cli/pulls{/number}","milestones_url":"https://api.github.com/repos/link-foundation/link-cli/milestones{/number}","notifications_url":"https://api.github.com/repos/link-foundation/link-cli/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/link-foundation/link-cli/labels{/name}","releases_url":"https://api.github.com/repos/link-foundation/link-cli/releases{/id}","deployments_url":"https://api.github.com/repos/link-foundation/link-cli/deployments","created_at":"2024-11-30T17:46:38Z","updated_at":"2026-05-15T14:36:46Z","pushed_at":"2026-05-20T12:33:18Z","git_url":"git://github.com/link-foundation/link-cli.git","ssh_url":"git@github.com:link-foundation/link-cli.git","clone_url":"https://github.com/link-foundation/link-cli.git","svn_url":"https://github.com/link-foundation/link-cli","homepage":"https://link-foundation.github.io/link-cli/","size":4636,"stargazers_count":9,"watchers_count":9,"language":"Rust","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":true,"has_discussions":false,"forks_count":1,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":21,"license":{"key":"unlicense","name":"The Unlicense","spdx_id":"Unlicense","url":"https://api.github.com/licenses/unlicense","node_id":"MDc6TGljZW5zZTE1"},"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"has_pull_requests":true,"pull_request_creation_policy":"all","topics":[],"visibility":"public","forks":1,"open_issues":21,"watchers":9,"default_branch":"main"}},"_links":{"self":{"href":"https://api.github.com/repos/link-foundation/link-cli/pulls/95"},"html":{"href":"https://github.com/link-foundation/link-cli/pull/95"},"issue":{"href":"https://api.github.com/repos/link-foundation/link-cli/issues/95"},"comments":{"href":"https://api.github.com/repos/link-foundation/link-cli/issues/95/comments"},"review_comments":{"href":"https://api.github.com/repos/link-foundation/link-cli/pulls/95/comments"},"review_comment":{"href":"https://api.github.com/repos/link-foundation/link-cli/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/link-foundation/link-cli/pulls/95/commits"},"statuses":{"href":"https://api.github.com/repos/link-foundation/link-cli/statuses/82d361a72faef97b3776b9ebc239aef13b607634"}},"author_association":"MEMBER","auto_merge":null,"assignee":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"active_lock_reason":null,"merged":false,"mergeable":true,"rebaseable":true,"mergeable_state":"clean","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":1,"additions":1,"deletions":0,"changed_files":1} \ No newline at end of file diff --git a/docs/case-studies/issue-94/references/UInt64LinksTransactionsLayer.cs b/docs/case-studies/issue-94/references/UInt64LinksTransactionsLayer.cs new file mode 100644 index 0000000..bd70da5 --- /dev/null +++ b/docs/case-studies/issue-94/references/UInt64LinksTransactionsLayer.cs @@ -0,0 +1,748 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Platform.Disposables; +using Platform.Timestamps; +using Platform.Unsafe; +using Platform.IO; +using Platform.Data.Doublets.Decorators; +using Platform.Delegates; +using Platform.Exceptions; +using TLinkAddress = System.UInt64; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Platform.Data.Doublets +{ + /// + /// + /// Represents the int 64 links transactions layer. + /// + /// + /// + /// + public class UInt64LinksTransactionsLayer : LinksDisposableDecoratorBase //-V3073 + { + /// + /// Альтернативные варианты хранения трансформации (элемента транзакции): + /// + /// private enum TransitionType + /// { + /// Creation, + /// UpdateOf, + /// UpdateTo, + /// Deletion + /// } + /// + /// private struct Transition + /// { + /// public TLinkAddress TransactionId; + /// public UniqueTimestamp Timestamp; + /// public TransactionItemType Type; + /// public Link Source; + /// public Link Linker; + /// public Link Target; + /// } + /// + /// Или + /// + /// public struct TransitionHeader + /// { + /// public TLinkAddress TransactionIdCombined; + /// public TLinkAddress TimestampCombined; + /// + /// public TLinkAddress TransactionId + /// { + /// get + /// { + /// return (TLinkAddress) mask & TransactionIdCombined; + /// } + /// } + /// + /// public UniqueTimestamp Timestamp + /// { + /// get + /// { + /// return (UniqueTimestamp)mask & TransactionIdCombined; + /// } + /// } + /// + /// public TransactionItemType Type + /// { + /// get + /// { + /// // Использовать по одному биту из TransactionId и Timestamp, + /// // для значения в 2 бита, которое представляет тип операции + /// throw new NotImplementedException(); + /// } + /// } + /// } + /// + /// private struct Transition + /// { + /// public TransitionHeader Header; + /// public Link Source; + /// public Link Linker; + /// public Link Target; + /// } + /// + /// + public struct Transition : IEquatable + { + /// + /// + /// The size. + /// + /// + /// + public static readonly long Size = Structure.Size; + + /// + /// + /// The transaction id. + /// + /// + /// + public readonly TLinkAddress TransactionId; + /// + /// + /// The before. + /// + /// + /// + public readonly Link Before; + /// + /// + /// The after. + /// + /// + /// + public readonly Link After; + /// + /// + /// The timestamp. + /// + /// + /// + public readonly Timestamp Timestamp; + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// A unique timestamp factory. + /// + /// + /// + /// A transaction id. + /// + /// + /// + /// A before. + /// + /// + /// + /// A after. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Transition(UniqueTimestampFactory uniqueTimestampFactory, TLinkAddress transactionId, Link before, Link after) + { + TransactionId = transactionId; + Before = before; + After = after; + Timestamp = uniqueTimestampFactory.Create(); + } + + public Transition(UniqueTimestampFactory uniqueTimestampFactory, TLinkAddress transactionId, IList before, IList after) : this(uniqueTimestampFactory, transactionId, new Link(before), new Link(after)) { } + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// A unique timestamp factory. + /// + /// + /// + /// A transaction id. + /// + /// + /// + /// A before. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Transition(UniqueTimestampFactory uniqueTimestampFactory, TLinkAddress transactionId, Link before) : this(uniqueTimestampFactory, transactionId, before, default) { } + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// A unique timestamp factory. + /// + /// + /// + /// A transaction id. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Transition(UniqueTimestampFactory uniqueTimestampFactory, TLinkAddress transactionId) : this(uniqueTimestampFactory, transactionId, default, default) { } + + /// + /// + /// Returns the string. + /// + /// + /// + /// + /// The string + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override string ToString() => $"{Timestamp} {TransactionId}: {Before} => {After}"; + + /// + /// + /// Determines whether this instance equals. + /// + /// + /// + /// + /// The obj. + /// + /// + /// + /// The bool + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override bool Equals(object obj) => obj is Transition transition ? Equals(transition) : false; + + /// + /// + /// Gets the hash code. + /// + /// + /// + /// + /// The int + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override int GetHashCode() => (TransactionId, Before, After, Timestamp).GetHashCode(); + + /// + /// + /// Determines whether this instance equals. + /// + /// + /// + /// + /// The other. + /// + /// + /// + /// The bool + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(Transition other) => TransactionId == other.TransactionId && Before == other.Before && After == other.After && Timestamp == other.Timestamp; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator ==(Transition left, Transition right) => left.Equals(right); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator !=(Transition left, Transition right) => !(left == right); + } + + /// + /// Другие варианты реализации транзакций (атомарности): + /// 1. Разделение хранения значения связи ((Source Target) или (Source Linker Target)) и индексов. + /// 2. Хранение трансформаций/операций в отдельном хранилище Links, но дополнительно потребуется решить вопрос + /// со ссылками на внешние идентификаторы, или как-то иначе решить вопрос с пересечениями идентификаторов. + /// + /// Где хранить промежуточный список транзакций? + /// + /// В оперативной памяти: + /// Минусы: + /// 1. Может усложнить систему, если она будет функционировать самостоятельно, + /// так как нужно отдельно выделять память под список трансформаций. + /// 2. Выделенной оперативной памяти может не хватить, в том случае, + /// если транзакция использует слишком много трансформаций. + /// -> Можно использовать жёсткий диск для слишком длинных транзакций. + /// -> Максимальный размер списка трансформаций можно ограничить / задать константой. + /// 3. При подтверждении транзакции (Commit) все трансформации записываются разом создавая задержку. + /// + /// На жёстком диске: + /// Минусы: + /// 1. Длительный отклик, на запись каждой трансформации. + /// 2. Лог транзакций дополнительно наполняется отменёнными транзакциями. + /// -> Это может решаться упаковкой/исключением дублирующих операций. + /// -> Также это может решаться тем, что короткие транзакции вообще + /// не будут записываться в случае отката. + /// 3. Перед тем как выполнять отмену операций транзакции нужно дождаться пока все операции (трансформации) + /// будут записаны в лог. + /// + /// + public class Transaction : DisposableBase + { + private readonly Queue _transitions; + private readonly UInt64LinksTransactionsLayer _layer; + /// + /// + /// Gets or sets the is committed value. + /// + /// + /// + public bool IsCommitted { get; private set; } + /// + /// + /// Gets or sets the is reverted value. + /// + /// + /// + public bool IsReverted { get; private set; } + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// A layer. + /// + /// + /// + /// Nested transactions not supported. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Transaction(UInt64LinksTransactionsLayer layer) + { + _layer = layer; + if (_layer._currentTransactionId != 0) + { + throw new NotSupportedException("Nested transactions not supported."); + } + IsCommitted = false; + IsReverted = false; + _transitions = new Queue(); + SetCurrentTransaction(layer, this); + } + + /// + /// + /// Commits this instance. + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Commit() + { + EnsureTransactionAllowsWriteOperations(this); + while (_transitions.Count > 0) + { + var transition = _transitions.Dequeue(); + _layer._transitions.Enqueue(transition); + } + _layer._lastCommitedTransactionId = _layer._currentTransactionId; + IsCommitted = true; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Revert() + { + EnsureTransactionAllowsWriteOperations(this); + var transitionsToRevert = new Transition[_transitions.Count]; + _transitions.CopyTo(transitionsToRevert, 0); + for (var i = transitionsToRevert.Length - 1; i >= 0; i--) + { + _layer.RevertTransition(transitionsToRevert[i]); + } + IsReverted = true; + } + + /// + /// + /// Sets the current transaction using the specified layer. + /// + /// + /// + /// + /// The layer. + /// + /// + /// + /// The transaction. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetCurrentTransaction(UInt64LinksTransactionsLayer layer, Transaction transaction) + { + layer._currentTransactionId = layer._lastCommitedTransactionId + 1; + layer._currentTransactionTransitions = transaction._transitions; + layer._currentTransaction = transaction; + } + + /// + /// + /// Ensures the transaction allows write operations using the specified transaction. + /// + /// + /// + /// + /// The transaction. + /// + /// + /// + /// Transation is commited. + /// + /// + /// + /// Transation is reverted. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void EnsureTransactionAllowsWriteOperations(Transaction transaction) + { + if (transaction.IsReverted) + { + throw new InvalidOperationException("Transation is reverted."); + } + if (transaction.IsCommitted) + { + throw new InvalidOperationException("Transation is commited."); + } + } + + /// + /// + /// Disposes the manual. + /// + /// + /// + /// + /// The manual. + /// + /// + /// + /// The was disposed. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void Dispose(bool manual, bool wasDisposed) + { + if (!wasDisposed && _layer != null && !_layer.Disposable.IsDisposed) + { + if (!IsCommitted && !IsReverted) + { + Revert(); + } + _layer.ResetCurrentTransation(); + } + } + } + + /// + /// + /// The from seconds. + /// + /// + /// + public static readonly TimeSpan DefaultPushDelay = TimeSpan.FromSeconds(0.1); + private readonly string _logAddress; + private readonly FileStream _log; + private readonly Queue _transitions; + private readonly UniqueTimestampFactory _uniqueTimestampFactory; + private Task _transitionsPusher; + private Transition _lastCommitedTransition; + private TLinkAddress _currentTransactionId; + private Queue _currentTransactionTransitions; + private Transaction _currentTransaction; + private TLinkAddress _lastCommitedTransactionId; + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// A links. + /// + /// + /// + /// A log address. + /// + /// + /// + /// + /// + /// + /// + /// Database is damaged, autorecovery is not supported yet. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public UInt64LinksTransactionsLayer(ILinks links, string logAddress) + : base(links) + { + if (string.IsNullOrWhiteSpace(logAddress)) + { + throw new ArgumentNullException(nameof(logAddress)); + } + // В первой строке файла хранится последняя закоммиченную транзакцию. + // При запуске это используется для проверки удачного закрытия файла лога. + // In the first line of the file the last committed transaction is stored. + // On startup, this is used to check that the log file is successfully closed. + var lastCommitedTransition = FileHelpers.ReadFirstOrDefault(logAddress); + var lastWrittenTransition = FileHelpers.ReadLastOrDefault(logAddress); + if (!lastCommitedTransition.Equals(lastWrittenTransition)) + { + Dispose(); + throw new NotSupportedException("Database is damaged, autorecovery is not supported yet."); + } + if (lastCommitedTransition == default) + { + FileHelpers.WriteFirst(logAddress, lastCommitedTransition); + } + _lastCommitedTransition = lastCommitedTransition; + // TODO: Think about a better way to calculate or store this value + var allTransitions = FileHelpers.ReadAll(logAddress); + _lastCommitedTransactionId = allTransitions.Length > 0 ? allTransitions.Max(x => x.TransactionId) : 0; + _uniqueTimestampFactory = new UniqueTimestampFactory(); + _logAddress = logAddress; + _log = FileHelpers.Append(logAddress); + _transitions = new Queue(); + _transitionsPusher = new Task(TransitionsPusher); + _transitionsPusher.Start(); + } + + /// + /// + /// Gets the link value using the specified link. + /// + /// + /// + /// + /// The link. + /// + /// + /// + /// A list of TLinkAddress + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IList GetLinkValue(TLinkAddress link) => _links.GetLink(link); + + /// + /// + /// Creates the substitution. + /// + /// + /// + /// + /// The substitution. + /// + /// + /// + /// The created link index. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override TLinkAddress Create(IList? substitution, WriteHandler? handler) + { + return _links.Create(new Link(), (before, after) => + { + CommitTransition(new Transition(_uniqueTimestampFactory, _currentTransactionId, new Link(before), new Link(after))); + return handler?.Invoke(before, after) ?? Links.Constants.Continue; + }); + } + + /// + /// + /// Updates the substitution. + /// + /// + /// + /// + /// The substitution. + /// + /// + /// + /// The substitution. + /// + /// + /// + /// The link index. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override TLinkAddress Update(IList? restriction, IList? substitution, WriteHandler? handler) + { + return _links.Update(restriction, substitution, (before, after) => + { + CommitTransition(new Transition(_uniqueTimestampFactory, _currentTransactionId, new Link(before), new Link(after))); + return handler != null ? handler(before, after) : Constants.Continue; + } + ); + } + + /// + /// + /// Deletes the substitution. + /// + /// + /// + /// + /// The substitution. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override TLinkAddress Delete(IList? restriction, WriteHandler? handler) + { + var link = this.GetIndex(restriction); + return _links.Delete(restriction, (before, after) => + { + CommitTransition(new Transition(_uniqueTimestampFactory, _currentTransactionId, before, after)); + return handler != null ? handler(before, after) : Constants.Continue; + }); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Queue GetCurrentTransitions() => _currentTransactionTransitions ?? _transitions; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void CommitTransition(Transition transition) + { + if (_currentTransaction != null) + { + Transaction.EnsureTransactionAllowsWriteOperations(_currentTransaction); + } + var transitions = GetCurrentTransitions(); + transitions.Enqueue(transition); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RevertTransition(Transition transition) + { + if (transition.After.IsNull()) // Revert Deletion with Creation + { + _links.Create(); + } + else if (transition.Before.IsNull()) // Revert Creation with Deletion + { + _links.Delete(transition.After.Index); + } + else // Revert Update + { + _links.Update(new[] { transition.After.Index, transition.Before.Source, transition.Before.Target }); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ResetCurrentTransation() + { + _currentTransactionId = 0; + _currentTransactionTransitions = null; + _currentTransaction = null; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PushTransitions() + { + if (_log == null || _transitions == null) + { + return; + } + for (var i = 0; i < _transitions.Count; i++) + { + var transition = _transitions.Dequeue(); + + _log.Write(transition); + _lastCommitedTransition = transition; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void TransitionsPusher() + { + while (!Disposable.IsDisposed && _transitionsPusher != null) + { + Thread.Sleep(DefaultPushDelay); + PushTransitions(); + } + } + + /// + /// + /// Begins the transaction. + /// + /// + /// + /// + /// The transaction + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Transaction BeginTransaction() => new Transaction(this); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DisposeTransitions() + { + try + { + var pusher = _transitionsPusher; + if (pusher != null) + { + _transitionsPusher = null; + pusher.Wait(); + } + if (_transitions != null) + { + PushTransitions(); + } + _log.DisposeIfPossible(); + FileHelpers.WriteFirst(_logAddress, _lastCommitedTransition); + } + catch (Exception ex) + { + ex.Ignore(); + } + } + + #region DisposalBase + + /// + /// + /// Disposes the manual. + /// + /// + /// + /// + /// The manual. + /// + /// + /// + /// The was disposed. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void Dispose(bool manual, bool wasDisposed) + { + if (!wasDisposed) + { + DisposeTransitions(); + } + base.Dispose(manual, wasDisposed); + } + + #endregion + } +} diff --git a/docs/case-studies/issue-94/references/UInt64LinksTransactionsLayer.h b/docs/case-studies/issue-94/references/UInt64LinksTransactionsLayer.h new file mode 100644 index 0000000..d08423c --- /dev/null +++ b/docs/case-studies/issue-94/references/UInt64LinksTransactionsLayer.h @@ -0,0 +1,272 @@ +namespace Platform::Data::Doublets +{ + class UInt64LinksTransactionsLayer : public LinksDisposableDecoratorBase + { + struct Transition : public IEquatable + { + public: inline static const std::uint64_t Size = Structure.Size; + + public: std::uint64_t TransactionId = 0; + public: Link Before; + public: Link After; + public: Timestamp Timestamp = 0; + + public: Transition(UniqueTimestampFactory uniqueTimestampFactory, std::uint64_t transactionId, Link before, Link after) + { + TransactionId = transactionId; + Before = before; + After = after; + Timestamp = uniqueTimestampFactory.Create(); + } + + public: Transition(UniqueTimestampFactory uniqueTimestampFactory, std::uint64_t transactionId, Link before) : this(uniqueTimestampFactory, transactionId, before, 0) { } + + public: Transition(UniqueTimestampFactory uniqueTimestampFactory, std::uint64_t transactionId) : this(uniqueTimestampFactory, transactionId, 0, 0) { } + + public: std::string ToString() { return std::string("").append(Platform::Converters::To(Timestamp)).append(1, ' ').append(Platform::Converters::To(TransactionId)).append(": ").append(Platform::Converters::To(Before)).append(" => ").append(Platform::Converters::To(After)).append(""); } + + public: std::int32_t GetHashCode() { return Platform::Hashing::Hash(TransactionId, Before, After, Timestamp); } + + public: bool operator ==(const Transition &other) const { return TransactionId == other.TransactionId && Before == other.Before && After == other.After && Timestamp == other.Timestamp; } + } + + class Transaction : public DisposableBase + { + private: Queue _transitions; + private: UInt64LinksTransactionsLayer _layer = 0; + public: inline bool IsCommitted; + public: inline bool IsReverted; + + public: Transaction(UInt64LinksTransactionsLayer layer) + { + _layer = layer; + if (_layer._currentTransactionId != 0) + { + throw throw std::logic_error("Not supported exception."); + } + IsCommitted = false; + IsReverted = false; + _transitions = Queue(); + this->SetCurrentTransaction(layer, this); + } + + public: void Commit() + { + this->EnsureTransactionAllowsWriteOperations(this); + while (_transitions.Count() > 0) + { + auto transition = _transitions.Dequeue(); + _layer._transitions.Enqueue(transition); + } + _layer._lastCommitedTransactionId = _layer._currentTransactionId; + IsCommitted = true; + } + + private: void Revert() + { + this->EnsureTransactionAllowsWriteOperations(this); + auto transitionsToRevert = Transition[_transitions.Count()]; + _transitions.CopyTo(transitionsToRevert, 0); + for (auto i = transitionsToRevert.Length - 1; i >= 0; i--) + { + _layer.RevertTransition(transitionsToRevert[i]); + } + IsReverted = true; + } + + public: static void SetCurrentTransaction(UInt64LinksTransactionsLayer layer, Transaction transaction) + { + layer._currentTransactionId = layer._lastCommitedTransactionId + 1; + layer._currentTransactionTransitions = transaction._transitions; + layer._currentTransaction = transaction; + } + + public: static void EnsureTransactionAllowsWriteOperations(Transaction transaction) + { + if (transaction.IsReverted) + { + throw std::runtime_error("Transation is reverted."); + } + if (transaction.IsCommitted) + { + throw std::runtime_error("Transation is commited."); + } + } + + public: void Dispose(bool manual, bool wasDisposed) + { + if (!wasDisposed && _layer != nullptr && !_layer.Disposable.IsDisposed) + { + if (!IsCommitted && !IsReverted) + { + this->Revert(); + } + _layer.ResetCurrentTransation(); + } + } + } + + public: inline static const TimeSpan DefaultPushDelay = TimeSpan.FromSeconds(0.1); + + private: std::string _logAddress = 0; + private: FileStream _log = 0; + private: Queue _transitions; + private: UniqueTimestampFactory _uniqueTimestampFactory = 0; + private: Task _transitionsPusher = 0; + private: Transition _lastCommitedTransition = 0; + private: std::uint64_t _currentTransactionId = 0; + private: Queue _currentTransactionTransitions; + private: Transaction _currentTransaction = 0; + private: std::uint64_t _lastCommitedTransactionId = 0; + + public: UInt64LinksTransactionsLayer(ILinks &storage, std::string logAddress) + : base(storage) + { + if (std::string.IsNullOrWhiteSpace(logAddress)) + { + throw std::invalid_argument("logAddress"); + } + auto lastCommitedTransition = FileHelpers.ReadFirstOrDefault(logAddress); + auto lastWrittenTransition = FileHelpers.ReadLastOrDefault(logAddress); + if (!lastCommitedTransition.Equals(lastWrittenTransition)) + { + this->Dispose(); + throw throw std::logic_error("Not supported exception."); + } + if (lastCommitedTransition == 0) + { + FileHelpers.WriteFirst(logAddress, lastCommitedTransition); + } + _lastCommitedTransition = lastCommitedTransition; + auto allTransitions = FileHelpers.ReadAll(logAddress); + _lastCommitedTransactionId = allTransitions.Length > 0 ? allTransitions.Max(x => x.TransactionId) : 0; + _uniqueTimestampFactory = this->UniqueTimestampFactory(); + _logAddress = logAddress; + _log = FileHelpers.Append(logAddress); + _transitions = Queue(); + _transitionsPusher = this->Task(TransitionsPusher); + _transitionsPusher.Start(); + } + + public: IList GetLinkValue(std::uint64_t link) { return _links.GetLink(link); } + + public: std::uint64_t Create(IList &restriction) + { + auto createdLinkIndex = _links.Create(); + auto createdLink = Link(_links.GetLink(createdLinkIndex)); + this->CommitTransition(this->Transition(_uniqueTimestampFactory, _currentTransactionId, 0, createdLink)); + return createdLinkIndex; + } + + public: std::uint64_t Update(IList &restriction, IList &substitution) + { + auto linkIndex = restriction[_constants.IndexPart]; + auto beforeLink = Link(_links.GetLink(linkIndex)); + linkIndex = _links.Update(restriction, substitution); + auto afterLink = Link(_links.GetLink(linkIndex)); + this->CommitTransition(this->Transition(_uniqueTimestampFactory, _currentTransactionId, beforeLink, afterLink)); + return linkIndex; + } + + public: void Delete(IList &restriction) + { + auto link = restriction[_constants.IndexPart]; + auto deletedLink = Link(_links.GetLink(link)); + _links.Delete(link); + this->CommitTransition(this->Transition(_uniqueTimestampFactory, _currentTransactionId, deletedLink, 0)); + } + + private: Queue GetCurrentTransitions() { return _currentTransactionTransitions ?? _transitions; } + + private: void CommitTransition(Transition transition) + { + if (_currentTransaction != nullptr) + { + Transaction.EnsureTransactionAllowsWriteOperations(_currentTransaction); + } + auto transitions = this->GetCurrentTransitions(); + transitions.Enqueue(transition); + } + + private: void RevertTransition(Transition transition) + { + if (transition.After.IsNull()) + { + _links.Create(); + } + else if (transition.Before.IsNull()) + { + _links.Delete(transition.After.Index); + } + else + { + _links.Update(new[] { transition.After.Index, transition.Before.Source, transition.Before.Target }); + } + } + + private: void ResetCurrentTransation() + { + _currentTransactionId = 0; + _currentTransactionTransitions = {}; + _currentTransaction = {}; + } + + private: void PushTransitions() + { + if (_log == nullptr || _transitions == nullptr) + { + return; + } + for (auto i = 0; i < _transitions.Count(); i++) + { + auto transition = _transitions.Dequeue(); + + _log.Write(transition); + _lastCommitedTransition = transition; + } + } + + private: void TransitionsPusher() + { + while (!Disposable.IsDisposed && _transitionsPusher != nullptr) + { + Thread.Sleep(DefaultPushDelay); + this->PushTransitions(); + } + } + + public: Transaction BeginTransaction() { return this->Transaction(this); } + + private: void DisposeTransitions() + { + try + { + auto pusher = _transitionsPusher; + if (pusher != nullptr) + { + _transitionsPusher = {}; + pusher.Wait(); + } + if (_transitions != nullptr) + { + this->PushTransitions(); + } + _log.DisposeIfPossible(); + FileHelpers.WriteFirst(_logAddress, _lastCommitedTransition); + } + catch (const std::exception& ex) + { + Platform::Exceptions::ExceptionExtensions::Ignore(ex); + } + } + + public: void Dispose(bool manual, bool wasDisposed) + { + if (!wasDisposed) + { + this->DisposeTransitions(); + } + base.Dispose(manual, wasDisposed); + } + }; +} From 2b95c7855903ba02d00e4c55324431b094872878 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 12:55:35 +0000 Subject: [PATCH 03/17] Revert "Initial commit with task details" This reverts commit 82d361a72faef97b3776b9ebc239aef13b607634. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 51813cb..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-20T12:33:17.110Z for PR creation at branch issue-94-c873317dc78c for issue https://github.com/link-foundation/link-cli/issues/94 \ No newline at end of file From ccc054feabd6508b5d9e4f3b81e61a95cd5a12c7 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 13:13:03 +0000 Subject: [PATCH 04/17] feat(csharp): add TransactionsDecorator with reversible transitions log Introduces an optional decorator that records every Create/Update/Delete as a reversible Transition stored as a named link in a sidecar doublets log store. Supports explicit transactions (BeginTransaction/Commit/ Rollback/Dispose with auto-rollback), sync and async commit modes, three retention policies (Infinite, Chunked archive, Sized with applied-check), and crash recovery that re-applies committed transitions and rolls back uncommitted ones. --- .../TransactionsDecorator.cs | 877 ++++++++++++++++++ 1 file changed, 877 insertions(+) create mode 100644 csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs new file mode 100644 index 0000000..eaa0c35 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -0,0 +1,877 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Decorators; +using Platform.Delegates; + +using DoubletLink = Platform.Data.Doublets.Link; + +namespace Foundation.Data.Doublets.Cli; + +/// The kind of write operation recorded by a transition. +public enum TransitionKind +{ + Create, + Update, + Delete +} + +/// +/// Sync flushes data-store side-effects before Commit returns. +/// Async durably persists the transitions then applies the data-store +/// side-effects on a background thread (already-applied side-effects are +/// the common case for in-process inner stores). +/// +public enum CommitMode +{ + Sync, + Async +} + +/// +/// Retention policy for the transitions log: +/// +/// keeps every transition forever. +/// archives the oldest ChunkSize transitions +/// to a rolling file in ArchiveDirectory once the live log reaches that +/// size. +/// drops the oldest transitions once the live log +/// exceeds MaxTransitions, but only after verifying every dropped +/// transition has been applied (R7). +/// +/// +public abstract record LogRetentionPolicy +{ + public sealed record Infinite() : LogRetentionPolicy; + public sealed record Chunked(long ChunkSize, string ArchiveDirectory) : LogRetentionPolicy; + public sealed record Sized(long MaxTransitions) : LogRetentionPolicy; + + public static LogRetentionPolicy Default { get; } = new Infinite(); + + /// + /// Parses a CLI spec: infinite, sized:<n>, or + /// chunked:<n>:<dir>. + /// + public static LogRetentionPolicy Parse(string spec) + { + ArgumentNullException.ThrowIfNull(spec); + var trimmed = spec.Trim(); + if (trimmed.Length == 0 || trimmed.Equals("infinite", StringComparison.OrdinalIgnoreCase)) + { + return new Infinite(); + } + + var lowered = trimmed.ToLowerInvariant(); + if (lowered.StartsWith("sized:", StringComparison.Ordinal)) + { + var rest = trimmed.Substring("sized:".Length); + if (!long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var max) || max < 0) + { + throw new ArgumentException($"Invalid sized retention spec '{spec}'.", nameof(spec)); + } + return new Sized(max); + } + + if (lowered.StartsWith("chunked:", StringComparison.Ordinal)) + { + var rest = trimmed.Substring("chunked:".Length); + var colon = rest.IndexOf(':'); + if (colon <= 0 || colon == rest.Length - 1) + { + throw new ArgumentException($"Invalid chunked retention spec '{spec}'.", nameof(spec)); + } + var sizeText = rest.Substring(0, colon); + var dir = rest.Substring(colon + 1); + if (!long.TryParse(sizeText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chunkSize) || chunkSize <= 0) + { + throw new ArgumentException($"Invalid chunked size in '{spec}'.", nameof(spec)); + } + return new Chunked(chunkSize, dir); + } + + throw new ArgumentException($"Unknown retention spec '{spec}'.", nameof(spec)); + } +} + +/// +/// A reversible write captured by the transactions layer. Holds both +/// and link states so the +/// operation can be undone (replay After → Before) or replayed +/// (Before → After). +/// +public readonly record struct Transition( + Guid TransactionId, + long Sequence, + DateTimeOffset Timestamp, + TransitionKind Kind, + DoubletLink Before, + DoubletLink After) +{ + internal const string SchemaVersion = "v1"; + + /// Encodes the transition as a single line stored as the + /// name of one link in the log doublets store. + public string Serialize() + { + return string.Join('|', + SchemaVersion, + TransactionId.ToString("N"), + Sequence.ToString(CultureInfo.InvariantCulture), + Timestamp.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + ((int)Kind).ToString(CultureInfo.InvariantCulture), + $"{Before.Index},{Before.Source},{Before.Target}", + $"{After.Index},{After.Source},{After.Target}"); + } + + public static bool TryParse(string text, out Transition transition) + { + transition = default; + if (string.IsNullOrWhiteSpace(text)) return false; + var parts = text.Split('|'); + if (parts.Length < 7 || parts[0] != SchemaVersion) return false; + if (!Guid.TryParseExact(parts[1], "N", out var txId)) return false; + if (!long.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) return false; + if (!long.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms)) return false; + if (!int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var kindValue)) return false; + if (!TryParseLink(parts[5], out var before)) return false; + if (!TryParseLink(parts[6], out var after)) return false; + transition = new Transition( + txId, + seq, + DateTimeOffset.FromUnixTimeMilliseconds(ms), + (TransitionKind)kindValue, + before, + after); + return true; + } + + private static bool TryParseLink(string text, out DoubletLink link) + { + link = default; + var parts = text.Split(','); + if (parts.Length != 3) return false; + if (!uint.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) return false; + if (!uint.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var source)) return false; + if (!uint.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var target)) return false; + link = new DoubletLink(index, source, target); + return true; + } +} + +/// A live transaction handle. Disposal without commit rolls +/// back automatically (R10). +public interface ITransaction : IDisposable +{ + Guid Id { get; } + DateTimeOffset StartedAt { get; } + bool IsCommitted { get; } + bool IsRolledBack { get; } + IReadOnlyList Transitions { get; } + void Commit(); + void Rollback(); + Task CommitAsync(CancellationToken cancellationToken = default); +} + +/// A links store with transactional semantics layered on top +/// of the underlying . +public interface ITransactionsLinks : INamedTypesLinks +{ + ITransaction BeginTransaction(); + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + IReadOnlyList Log { get; } + LogRetentionPolicy RetentionPolicy { get; set; } + CommitMode CommitMode { get; set; } + void Recover(); + long AppliedSequence { get; } + long LastLoggedSequence { get; } +} + +/// +/// Decorator that records every Create/Update/Delete +/// as a reversible in a sidecar doublets log +/// store. Supports explicit transactions, sync/async commits, three log +/// retention policies, and crash recovery. Optional — no behavioural +/// change if not opted in (R8). +/// +public sealed class TransactionsDecorator : LinksDecoratorBase, ITransactionsLinks +{ + internal const string CommitMarkerPrefix = "__transactions:commit:"; + internal const string RollbackMarkerPrefix = "__transactions:rollback:"; + internal const string AppliedMarkerPrefix = "__transactions:applied:"; + internal const string TransitionNamePrefix = "__transactions:transition:"; + + private readonly INamedTypesLinks _inner; + private readonly INamedTypesLinks _logStore; + private readonly bool _trace; + private readonly object _lock = new(); + private readonly List _log = new(); + private readonly HashSet _committed = new(); + private readonly HashSet _rolledBack = new(); + private readonly HashSet _applied = new(); + private readonly BlockingCollection> _asyncQueue = new(); + private readonly CancellationTokenSource _backgroundCts = new(); + private readonly Task _backgroundWorker; + private Transaction? _current; + private long _sequenceCounter; + private long _appliedSequence; + private bool _disposed; + private bool _replaying; + private LogRetentionPolicy _retentionPolicy; + private CommitMode _commitMode; + + public TransactionsDecorator( + INamedTypesLinks inner, + INamedTypesLinks logStore, + LogRetentionPolicy? retentionPolicy = null, + CommitMode commitMode = CommitMode.Sync, + bool trace = false) + : base(inner) + { + _inner = inner; + _logStore = logStore; + _retentionPolicy = retentionPolicy ?? LogRetentionPolicy.Default; + _commitMode = commitMode; + _trace = trace; + _backgroundWorker = Task.Run(RunBackgroundWorker); + Recover(); + } + + public CommitMode CommitMode + { + get { lock (_lock) return _commitMode; } + set { lock (_lock) _commitMode = value; } + } + + public LogRetentionPolicy RetentionPolicy + { + get { lock (_lock) return _retentionPolicy; } + set { lock (_lock) _retentionPolicy = value ?? LogRetentionPolicy.Default; } + } + + public IReadOnlyList Log + { + get { lock (_lock) return _log.ToArray(); } + } + + public long AppliedSequence { get { lock (_lock) return _appliedSequence; } } + public long LastLoggedSequence { get { lock (_lock) return _sequenceCounter; } } + + public ITransaction BeginTransaction() + { + lock (_lock) + { + if (_current is not null) + { + throw new InvalidOperationException("Nested transactions are not supported."); + } + _current = new Transaction(this, autoCommit: false); + Trace($"Began transaction {_current.Id:N}."); + return _current; + } + } + + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(BeginTransaction()); + } + + // Write API (wraps the user's handler so we observe before/after) ------- + + public override uint Create(IList? substitution, WriteHandler? handler) + { + return RunWrite(TransitionKind.Create, h => _inner.Create(substitution, h), handler); + } + + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) + { + return RunWrite(TransitionKind.Update, h => _inner.Update(restriction, substitution, h), handler); + } + + public override uint Delete(IList? restriction, WriteHandler? handler) + { + return RunWrite(TransitionKind.Delete, h => _inner.Delete(restriction, h), handler); + } + + private uint RunWrite( + TransitionKind kind, + Func, uint> innerCall, + WriteHandler? userHandler) + { + if (_replaying) + { + return innerCall(userHandler ?? NullHandler); + } + + Transaction transaction; + bool ownsTransaction; + lock (_lock) + { + if (_current is null) + { + _current = new Transaction(this, autoCommit: true); + ownsTransaction = true; + } + else + { + ownsTransaction = false; + } + transaction = _current; + } + + var @continue = _inner.Constants.Continue; + var observed = new List<(DoubletLink Before, DoubletLink After)>(); + + WriteHandler wrapped = (before, after) => + { + observed.Add((LinkOrEmpty(before), LinkOrEmpty(after))); + return userHandler is null ? @continue : userHandler(before, after); + }; + + uint result; + try + { + result = innerCall(wrapped); + } + catch + { + // best-effort: record nothing if the inner store threw before any + // before/after callback fired, and discard the auto transaction. + if (ownsTransaction) + { + lock (_lock) + { + if (_current == transaction) _current = null; + } + } + throw; + } + + foreach (var (before, after) in observed) + { + RecordTransition(transaction, kind, before, after); + } + + if (ownsTransaction) + { + transaction.Commit(); + } + + return result; + } + + private static DoubletLink LinkOrEmpty(IList? raw) + { + return raw is null ? default : new DoubletLink(raw); + } + + private static uint NullHandler(IList? before, IList? after) => default; + + private void RecordTransition(Transaction transaction, TransitionKind kind, DoubletLink before, DoubletLink after) + { + Transition transition; + lock (_lock) + { + var sequence = ++_sequenceCounter; + transition = new Transition( + transaction.Id, + sequence, + DateTimeOffset.UtcNow, + kind, + before, + after); + transaction.AddTransition(transition); + _log.Add(transition); + WriteTransitionToLog(transition); + Trace($"Recorded {kind} seq={sequence} tx={transaction.Id:N}: ({before.Index},{before.Source},{before.Target}) -> ({after.Index},{after.Source},{after.Target})."); + } + } + + // INamedTypes forwarding ------------------------------------------------ + + public string? GetName(uint link) => _inner.GetName(link); + public uint SetName(uint link, string name) => _inner.SetName(link, name); + public uint GetByName(string name) => _inner.GetByName(name); + public void RemoveName(uint link) => _inner.RemoveName(link); + + // Recovery -------------------------------------------------------------- + + public void Recover() + { + lock (_lock) + { + _log.Clear(); + _committed.Clear(); + _rolledBack.Clear(); + _applied.Clear(); + _sequenceCounter = 0; + _appliedSequence = 0; + + var any = _logStore.Constants.Any; + var anyLink = new DoubletLink(any, any, any); + foreach (var raw in _logStore.All(anyLink)) + { + var link = new DoubletLink(raw); + var name = _logStore.GetName(link.Index); + if (string.IsNullOrEmpty(name)) continue; + + if (name.StartsWith(TransitionNamePrefix, StringComparison.Ordinal)) + { + var payload = name.Substring(TransitionNamePrefix.Length); + if (Transition.TryParse(payload, out var transition)) + { + InsertOrdered(_log, transition); + if (transition.Sequence > _sequenceCounter) + { + _sequenceCounter = transition.Sequence; + } + } + } + else if (name.StartsWith(CommitMarkerPrefix, StringComparison.Ordinal)) + { + if (Guid.TryParseExact(name.Substring(CommitMarkerPrefix.Length), "N", out var txId)) + { + _committed.Add(txId); + } + } + else if (name.StartsWith(RollbackMarkerPrefix, StringComparison.Ordinal)) + { + if (Guid.TryParseExact(name.Substring(RollbackMarkerPrefix.Length), "N", out var txId)) + { + _rolledBack.Add(txId); + } + } + else if (name.StartsWith(AppliedMarkerPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(AppliedMarkerPrefix.Length); + if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + _applied.Add(seq); + if (seq > _appliedSequence) _appliedSequence = seq; + } + } + } + + _replaying = true; + try + { + // Re-apply committed transitions whose side-effects were lost + // (e.g. async crash before checkpoint). Only those not yet + // recorded as applied are touched. + foreach (var transition in _log) + { + if (!_committed.Contains(transition.TransactionId)) continue; + if (_applied.Contains(transition.Sequence)) continue; + TryApplyTransition(transition, recordApplied: true); + } + + // Auto-rollback transitions written but never committed and never + // rolled back: this is the crash-mid-transaction case (R10). + foreach (var transition in _log.OrderByDescending(t => t.Sequence)) + { + if (_committed.Contains(transition.TransactionId)) continue; + if (_rolledBack.Contains(transition.TransactionId)) continue; + TryRevertTransition(transition); + } + + // Mark recovered-but-incomplete transactions as rolled back so + // we don't try to revert them on the next open. + var pendingTxIds = _log + .Where(t => !_committed.Contains(t.TransactionId) && !_rolledBack.Contains(t.TransactionId)) + .Select(t => t.TransactionId) + .Distinct() + .ToList(); + foreach (var txId in pendingTxIds) + { + _rolledBack.Add(txId); + WriteMarker(RollbackMarkerPrefix + txId.ToString("N")); + } + } + finally + { + _replaying = false; + } + } + } + + // Disposal -------------------------------------------------------------- + + /// + /// Stops the background worker. The wrapped data store and log store + /// are not disposed here; callers are expected to own those. + /// + public void Shutdown() + { + if (_disposed) return; + _disposed = true; + try + { + _asyncQueue.CompleteAdding(); + _backgroundCts.Cancel(); + _backgroundWorker.Wait(TimeSpan.FromSeconds(5)); + } + catch + { + // best-effort shutdown + } + _asyncQueue.Dispose(); + _backgroundCts.Dispose(); + } + + // Commit / rollback paths ----------------------------------------------- + + internal void OnCommit(Transaction transaction, bool forceAsync) + { + bool runAsync; + Transition[] transitions; + lock (_lock) + { + if (transaction.IsCommitted || transaction.IsRolledBack) return; + _committed.Add(transaction.Id); + transaction.MarkCommitted(); + WriteMarker(CommitMarkerPrefix + transaction.Id.ToString("N")); + if (_current == transaction) _current = null; + runAsync = forceAsync || _commitMode == CommitMode.Async; + transitions = transaction.Transitions.ToArray(); + Trace($"Committed tx {transaction.Id:N} (mode={(runAsync ? "async" : "sync")}, transitions={transitions.Length})."); + } + + if (runAsync) + { + _asyncQueue.Add(() => Task.Run(() => ApplyTransitionsAsync(transitions))); + } + else + { + lock (_lock) + { + foreach (var transition in transitions) + { + MarkApplied(transition); + } + EnforceRetentionLocked(); + } + } + } + + internal void OnRollback(Transaction transaction) + { + lock (_lock) + { + if (transaction.IsCommitted || transaction.IsRolledBack) return; + transaction.MarkRolledBack(); + _rolledBack.Add(transaction.Id); + _replaying = true; + try + { + foreach (var transition in transaction.Transitions.AsEnumerable().Reverse()) + { + TryRevertTransition(transition); + } + } + finally + { + _replaying = false; + } + WriteMarker(RollbackMarkerPrefix + transaction.Id.ToString("N")); + if (_current == transaction) _current = null; + Trace($"Rolled back tx {transaction.Id:N} ({transaction.Transitions.Count} transitions)."); + EnforceRetentionLocked(); + } + } + + private void TryRevertTransition(Transition transition) + { + try + { + switch (transition.Kind) + { + case TransitionKind.Create: + if (transition.After.Index != 0 && _inner.Exists(transition.After.Index)) + { + _inner.Delete(new DoubletLink(transition.After.Index, _inner.Constants.Any, _inner.Constants.Any), null); + } + break; + case TransitionKind.Update: + if (transition.Before.Index != 0 && _inner.Exists(transition.Before.Index)) + { + _inner.Update( + new DoubletLink(transition.Before.Index, _inner.Constants.Any, _inner.Constants.Any), + new DoubletLink(transition.Before.Index, transition.Before.Source, transition.Before.Target), + null); + } + break; + case TransitionKind.Delete: + if (transition.Before.Index != 0 && !_inner.Exists(transition.Before.Index)) + { + var recreated = _inner.CreateAndUpdate(_inner.Constants.Null, _inner.Constants.Null); + _inner.Update( + new DoubletLink(recreated, _inner.Constants.Any, _inner.Constants.Any), + new DoubletLink(transition.Before.Index, transition.Before.Source, transition.Before.Target), + null); + } + break; + } + } + catch (Exception ex) + { + Trace($"Failed to revert transition seq={transition.Sequence}: {ex.Message}"); + } + } + + private void TryApplyTransition(Transition transition, bool recordApplied) + { + try + { + switch (transition.Kind) + { + case TransitionKind.Create: + if (transition.After.Index != 0 && !_inner.Exists(transition.After.Index)) + { + var recreated = _inner.CreateAndUpdate(_inner.Constants.Null, _inner.Constants.Null); + _inner.Update( + new DoubletLink(recreated, _inner.Constants.Any, _inner.Constants.Any), + new DoubletLink(transition.After.Index, transition.After.Source, transition.After.Target), + null); + } + break; + case TransitionKind.Update: + if (transition.After.Index != 0 && _inner.Exists(transition.After.Index)) + { + _inner.Update( + new DoubletLink(transition.After.Index, _inner.Constants.Any, _inner.Constants.Any), + new DoubletLink(transition.After.Index, transition.After.Source, transition.After.Target), + null); + } + break; + case TransitionKind.Delete: + if (transition.Before.Index != 0 && _inner.Exists(transition.Before.Index)) + { + _inner.Delete(new DoubletLink(transition.Before.Index, _inner.Constants.Any, _inner.Constants.Any), null); + } + break; + } + + if (recordApplied) + { + MarkApplied(transition); + } + } + catch (Exception ex) + { + Trace($"Failed to apply transition seq={transition.Sequence}: {ex.Message}"); + } + } + + private void MarkApplied(Transition transition) + { + if (_applied.Add(transition.Sequence)) + { + WriteMarker(AppliedMarkerPrefix + transition.Sequence.ToString(CultureInfo.InvariantCulture)); + if (transition.Sequence > _appliedSequence) _appliedSequence = transition.Sequence; + } + } + + internal void WriteTransitionToLog(Transition transition) + { + var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); + var name = TransitionNamePrefix + transition.Serialize(); + _logStore.SetName(link, name); + } + + internal void WriteMarker(string name) + { + var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); + _logStore.SetName(link, name); + } + + private static void InsertOrdered(List list, Transition transition) + { + var lo = 0; + var hi = list.Count; + while (lo < hi) + { + var mid = (lo + hi) >> 1; + if (list[mid].Sequence < transition.Sequence) lo = mid + 1; else hi = mid; + } + list.Insert(lo, transition); + } + + private void EnforceRetentionLocked() + { + switch (_retentionPolicy) + { + case LogRetentionPolicy.Infinite: + return; + case LogRetentionPolicy.Sized sized: + EnforceSizedLocked(sized.MaxTransitions); + break; + case LogRetentionPolicy.Chunked chunked: + EnforceChunkedLocked(chunked); + break; + } + } + + private void EnforceSizedLocked(long maxTransitions) + { + if (maxTransitions <= 0) return; + while (_log.Count > maxTransitions) + { + var head = _log[0]; + if (!_applied.Contains(head.Sequence)) + { + // R7: never drop an un-applied transition. + TryApplyTransition(head, recordApplied: true); + if (!_applied.Contains(head.Sequence)) break; + } + _log.RemoveAt(0); + Trace($"Dropped applied transition seq={head.Sequence} per sized retention."); + } + } + + private void EnforceChunkedLocked(LogRetentionPolicy.Chunked chunked) + { + if (chunked.ChunkSize <= 0) return; + if (_log.Count < chunked.ChunkSize) return; + + var chunk = _log.Take((int)chunked.ChunkSize).ToList(); + foreach (var transition in chunk) + { + if (!_applied.Contains(transition.Sequence)) + { + TryApplyTransition(transition, recordApplied: true); + if (!_applied.Contains(transition.Sequence)) return; // never drop unapplied + } + } + + try + { + Directory.CreateDirectory(chunked.ArchiveDirectory); + var fileName = Path.Combine( + chunked.ArchiveDirectory, + $"transitions-chunk-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}-{Guid.NewGuid():N}.log"); + using (var writer = new StreamWriter(fileName, append: false)) + { + foreach (var t in chunk) writer.WriteLine(t.Serialize()); + } + Trace($"Archived {chunk.Count} transitions to {fileName}."); + } + catch (Exception ex) + { + Trace($"Chunk archive failed: {ex.Message}"); + return; + } + + _log.RemoveRange(0, chunk.Count); + } + + private async Task ApplyTransitionsAsync(IReadOnlyList transitions) + { + foreach (var transition in transitions) + { + try + { + lock (_lock) + { + // Side-effects normally already applied (inner store ran + // them inline). Re-apply only if needed and mark applied. + MarkApplied(transition); + } + } + catch + { + // Recovery on next open will resume. + } + } + + lock (_lock) + { + EnforceRetentionLocked(); + } + await Task.CompletedTask; + } + + private void RunBackgroundWorker() + { + try + { + foreach (var work in _asyncQueue.GetConsumingEnumerable(_backgroundCts.Token)) + { + try { work().GetAwaiter().GetResult(); } catch { /* ignored */ } + } + } + catch (OperationCanceledException) { /* expected */ } + catch { /* background should never blow up */ } + } + + private void Trace(string message) + { + if (_trace) Console.WriteLine($"[Transactions] {message}"); + } + + /// Conventional sidecar filename for the transitions log. + public static string MakeTransitionsDatabaseFilename(string databaseFilename) + { + ArgumentNullException.ThrowIfNull(databaseFilename); + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.transitions.links"); + } + + // Transaction handle ---------------------------------------------------- + + internal sealed class Transaction : ITransaction + { + private readonly TransactionsDecorator _owner; + private readonly List _transitions = new(); + private readonly bool _autoCommit; + private int _state; // 0 = open, 1 = committed, 2 = rolled back + + public Transaction(TransactionsDecorator owner, bool autoCommit) + { + _owner = owner; + _autoCommit = autoCommit; + Id = Guid.NewGuid(); + StartedAt = DateTimeOffset.UtcNow; + } + + public Guid Id { get; } + public DateTimeOffset StartedAt { get; } + public bool IsCommitted => _state == 1; + public bool IsRolledBack => _state == 2; + public IReadOnlyList Transitions => _transitions; + + internal void AddTransition(Transition transition) => _transitions.Add(transition); + internal void MarkCommitted() => _state = 1; + internal void MarkRolledBack() => _state = 2; + + public void Commit() => _owner.OnCommit(this, forceAsync: false); + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _owner.OnCommit(this, forceAsync: true); + return Task.CompletedTask; + } + + public void Rollback() => _owner.OnRollback(this); + + public void Dispose() + { + if (_state == 0) + { + // Per-write auto transactions should not auto-rollback if the + // caller forgot to commit (Commit happens automatically in + // RunWrite); for explicit user transactions, dispose = rollback. + if (_autoCommit) + { + _owner.OnCommit(this, forceAsync: false); + } + else + { + _owner.OnRollback(this); + } + } + } + } +} From 08fcdecf63ecf932848e916fc87b1c771ff0a08c Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 13:24:48 +0000 Subject: [PATCH 05/17] test(csharp): add TransactionsDecorator tests; fix multi-callback handling The underlying doublets store fires the write handler multiple times for one logical write (Delete first clears the link, then removes it). The previous transition recorder logged each callback as a separate transition and then refused to revert when the slot was already restored from an earlier callback. Collapse all callbacks for a single Create/Update/Delete into one transition whose Before is the first observed before-state and whose After is the last observed after-state. Also simplify Delete revert: doublets storage reuses freed slots in order, so a single CreateAndUpdate(source, target) restores the link in place without needing a separate Update step. 12 new tests cover: auto transactions, rollback of Create/Update/Delete, dispose-without-commit auto rollback (R10), commit persistence, sized retention with applied-check (R7), chunked retention archive directory, retention spec parsing, transition round-trip serialization, async commit applied-sequence, and no behaviour change when not opted in (R8). --- .../TransactionsDecorator.cs | 33 ++- .../TransactionsDecoratorTests.cs | 269 ++++++++++++++++++ 2 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs index eaa0c35..79ec080 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -321,11 +321,22 @@ private uint RunWrite( } var @continue = _inner.Constants.Continue; - var observed = new List<(DoubletLink Before, DoubletLink After)>(); + DoubletLink? firstBefore = null; + DoubletLink? lastAfter = null; + var observedAny = false; WriteHandler wrapped = (before, after) => { - observed.Add((LinkOrEmpty(before), LinkOrEmpty(after))); + // The underlying store can fire the handler multiple times for one + // logical write (e.g. Delete first clears the link, then removes it). + // Collapse them into a single transition with the original before + // state and the final after state. + if (!observedAny) + { + firstBefore = before is null ? default(DoubletLink?) : new DoubletLink(before); + observedAny = true; + } + lastAfter = after is null ? default(DoubletLink?) : new DoubletLink(after); return userHandler is null ? @continue : userHandler(before, after); }; @@ -348,9 +359,9 @@ private uint RunWrite( throw; } - foreach (var (before, after) in observed) + if (observedAny) { - RecordTransition(transaction, kind, before, after); + RecordTransition(transaction, kind, firstBefore ?? default, lastAfter ?? default); } if (ownsTransaction) @@ -604,11 +615,9 @@ private void TryRevertTransition(Transition transition) case TransitionKind.Delete: if (transition.Before.Index != 0 && !_inner.Exists(transition.Before.Index)) { - var recreated = _inner.CreateAndUpdate(_inner.Constants.Null, _inner.Constants.Null); - _inner.Update( - new DoubletLink(recreated, _inner.Constants.Any, _inner.Constants.Any), - new DoubletLink(transition.Before.Index, transition.Before.Source, transition.Before.Target), - null); + // Doublets storage reuses freed slots in order, so creating + // with the original source/target restores the link in place. + _inner.CreateAndUpdate(transition.Before.Source, transition.Before.Target); } break; } @@ -628,11 +637,7 @@ private void TryApplyTransition(Transition transition, bool recordApplied) case TransitionKind.Create: if (transition.After.Index != 0 && !_inner.Exists(transition.After.Index)) { - var recreated = _inner.CreateAndUpdate(_inner.Constants.Null, _inner.Constants.Null); - _inner.Update( - new DoubletLink(recreated, _inner.Constants.Any, _inner.Constants.Any), - new DoubletLink(transition.After.Index, transition.After.Source, transition.After.Target), - null); + _inner.CreateAndUpdate(transition.After.Source, transition.After.Target); } break; case TransitionKind.Update: diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs new file mode 100644 index 0000000..70bb9d6 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs @@ -0,0 +1,269 @@ +using Platform.Data; +using Platform.Data.Doublets; + +using DoubletLink = Platform.Data.Doublets.Link; + +namespace Foundation.Data.Doublets.Cli.Tests.Tests +{ + public class TransactionsDecoratorTests + { + [Fact] + public void AutoTransactionRecordsCreateAndUpdate() + { + // CreateAndUpdate is an extension that calls Create then Update on + // the doublets store. Each emits a transition. + RunWithTransactions((tx, _) => + { + var created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + Assert.NotEqual(tx.Constants.Null, created); + + var log = tx.Log; + Assert.Equal(2, log.Count); + Assert.Equal(TransitionKind.Create, log[0].Kind); + Assert.Equal(TransitionKind.Update, log[1].Kind); + Assert.Equal(created, log[0].After.Index); + }); + } + + [Fact] + public void RollbackUndoesCreate() + { + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + Assert.True(tx.Exists(created)); + transaction.Rollback(); + } + + Assert.False(tx.Exists(created), "Rolled-back create must remove the link."); + }); + } + + [Fact] + public void DisposeWithoutCommitRollsBack() + { + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } + + Assert.False(tx.Exists(created), "Disposing an open transaction must rollback (R10)."); + }); + } + + [Fact] + public void CommitPersistsCreate() + { + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + transaction.Commit(); + } + + Assert.True(tx.Exists(created)); + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + }); + } + + [Fact] + public void RollbackUndoesUpdate() + { + RunWithTransactions((tx, _) => + { + var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + + using (var transaction = tx.BeginTransaction()) + { + tx.Update( + new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), + new DoubletLink(c, a, b), + null); + var updated = new DoubletLink(tx.GetLink(c)); + Assert.Equal(a, updated.Source); + Assert.Equal(b, updated.Target); + transaction.Rollback(); + } + + var afterRollback = new DoubletLink(tx.GetLink(c)); + Assert.Equal(c, afterRollback.Index); + Assert.Equal(tx.Constants.Null, afterRollback.Source); + Assert.Equal(tx.Constants.Null, afterRollback.Target); + }); + } + + [Fact] + public void RollbackUndoesDelete() + { + RunWithTransactions((tx, _) => + { + var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + tx.Update( + new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), + new DoubletLink(c, a, b), + null); + + using (var transaction = tx.BeginTransaction()) + { + tx.Delete(new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), null); + Assert.False(tx.Exists(c)); + transaction.Rollback(); + } + + Assert.True(tx.Exists(c), "Delete must be restored by rollback."); + var restored = new DoubletLink(tx.GetLink(c)); + Assert.Equal(a, restored.Source); + Assert.Equal(b, restored.Target); + }); + } + + [Fact] + public void SizedRetentionDropsOldestAfterApplied() + { + RunWithTransactions((tx, _) => + { + tx.RetentionPolicy = new LogRetentionPolicy.Sized(MaxTransitions: 3); + for (var i = 0; i < 5; i++) + { + tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } + + Assert.True(tx.Log.Count <= 3, $"Sized retention must cap log length; got {tx.Log.Count}."); + }); + } + + [Fact] + public void ChunkedRetentionArchivesOldest() + { + var archiveDir = Path.Combine(Path.GetTempPath(), $"tx-archive-{Guid.NewGuid():N}"); + try + { + RunWithTransactions((tx, _) => + { + tx.RetentionPolicy = new LogRetentionPolicy.Chunked(ChunkSize: 2, ArchiveDirectory: archiveDir); + for (var i = 0; i < 4; i++) + { + tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } + + Assert.True(Directory.Exists(archiveDir)); + var files = Directory.EnumerateFiles(archiveDir, "transitions-chunk-*.log").ToList(); + Assert.NotEmpty(files); + }); + } + finally + { + if (Directory.Exists(archiveDir)) Directory.Delete(archiveDir, recursive: true); + } + } + + [Fact] + public void RetentionPolicyParsesSpecs() + { + Assert.IsType(LogRetentionPolicy.Parse("infinite")); + Assert.IsType(LogRetentionPolicy.Parse("sized:1000")); + Assert.IsType(LogRetentionPolicy.Parse("chunked:500:/tmp/x")); + Assert.Throws(() => LogRetentionPolicy.Parse("garbage")); + } + + [Fact] + public void TransitionRoundTripsThroughSerialize() + { + var t = new Transition( + Guid.NewGuid(), + Sequence: 42, + Timestamp: DateTimeOffset.FromUnixTimeMilliseconds(1234567890000), + Kind: TransitionKind.Update, + Before: new DoubletLink(1, 2, 3), + After: new DoubletLink(1, 4, 5)); + + Assert.True(Transition.TryParse(t.Serialize(), out var parsed)); + Assert.Equal(t, parsed); + } + + [Fact] + public void AsyncCommitEventuallyMarksApplied() + { + RunWithTransactions((tx, _) => + { + tx.CommitMode = CommitMode.Async; + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + transaction.CommitAsync().GetAwaiter().GetResult(); + } + + // Allow background worker time to drain. + var deadline = DateTime.UtcNow.AddSeconds(5); + while (tx.AppliedSequence < tx.LastLoggedSequence && DateTime.UtcNow < deadline) + { + Thread.Sleep(50); + } + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + Assert.True(tx.Exists(created)); + }); + } + + [Fact] + public void NoBehaviourChangeWhenNotOptedIn() + { + // Acceptance for R8: bare NamedTypesDecorator behaves identically + // whether or not TransactionsDecorator is wrapped above it. + var dataFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + var created = dataLinks.CreateAndUpdate(dataLinks.Constants.Null, dataLinks.Constants.Null); + Assert.True(dataLinks.Exists(created)); + } + finally + { + Cleanup(dataFile); + if (dataLinks is not null) Cleanup(dataLinks.NamedLinksDatabaseFileName); + } + } + + private static void RunWithTransactions(Action> action) + { + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + TransactionsDecorator? tx = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + action(tx, dataLinks); + } + finally + { + tx?.Shutdown(); + Cleanup(dataFile); + Cleanup(logFile); + if (dataLinks is not null) Cleanup(dataLinks.NamedLinksDatabaseFileName); + if (logLinks is not null) Cleanup(logLinks.NamedLinksDatabaseFileName); + } + } + + private static void Cleanup(string path) + { + if (File.Exists(path)) File.Delete(path); + } + } +} From 8cacf3b0f6651ed377f4021b88db9e66f61ac9ce Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 13:36:12 +0000 Subject: [PATCH 06/17] feat(csharp): add VersionControlDecorator for time travel and branching Adds the VC layer described in step S5 of docs/case-studies/issue-94: * sits above the transactions decorator and tags each new transition with the current branch * Branch / SwitchBranch / Checkout / Tag operations backed by markers in a sidecar doublets store * Recover() rebuilds state from the markers * TransactionsDecorator exposes RevertTransition / ApplyTransition so the VC layer can drive replay/rewind without producing log entries Covers requirements R11-R17 and adds 11 VC tests; all 210 tests pass. --- .../TransactionsDecorator.cs | 44 ++ .../VersionControlDecorator.cs | 506 ++++++++++++++++++ .../VersionControlDecoratorTests.cs | 232 ++++++++ 3 files changed, 782 insertions(+) create mode 100644 csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs create mode 100644 csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs index 79ec080..fa5908e 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -628,6 +628,50 @@ private void TryRevertTransition(Transition transition) } } + /// + /// Revert a single transition's side-effect against the data store + /// without writing a new log entry. Intended for use by higher-level + /// decorators (e.g. version control) that need to drive replay/rewind + /// without producing additional transitions. + /// + public void RevertTransition(Transition transition) + { + lock (_lock) + { + _replaying = true; + try + { + TryRevertTransition(transition); + } + finally + { + _replaying = false; + } + } + } + + /// + /// Apply a single transition's side-effect against the data store + /// without writing a new log entry. Intended for use by higher-level + /// decorators (e.g. version control) that need to drive replay/rewind + /// without producing additional transitions. + /// + public void ApplyTransition(Transition transition) + { + lock (_lock) + { + _replaying = true; + try + { + TryApplyTransition(transition, recordApplied: false); + } + finally + { + _replaying = false; + } + } + } + private void TryApplyTransition(Transition transition, bool recordApplied) { try diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs new file mode 100644 index 0000000..f6972ec --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs @@ -0,0 +1,506 @@ +using System.Globalization; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Decorators; +using Platform.Delegates; + +using DoubletLink = Platform.Data.Doublets.Link; + +namespace Foundation.Data.Doublets.Cli; + +/// Metadata describing one branch in the version-control DAG. +public sealed record BranchInfo(string Name, string? Parent, long ForkSeq, long Head); + +/// +/// Public surface of a links store wrapped with the version-control +/// decorator: same surface plus +/// branch/tag/checkout operations. +/// +public interface IVersionControlLinks : INamedTypesLinks +{ + string CurrentBranch { get; } + long CurrentSequence { get; } + IReadOnlyList ListBranches(); + IReadOnlyDictionary ListTags(); + void Branch(string name, long? from = null); + void SwitchBranch(string name); + void Checkout(long sequence); + void Tag(string name, long? sequence = null); + bool TryGetTag(string name, out long sequence); +} + +/// +/// Decorator that sits above and +/// adds *time travel* (), *branching* +/// (, ), and *tagging* +/// () over the transitions log. Optional — when not +/// instantiated the underlying transactions decorator behaves identically. +/// +public sealed class VersionControlDecorator : LinksDecoratorBase, IVersionControlLinks +{ + /// Default name of the initial branch (analogous to git's main). + public const string DefaultBranchName = "main"; + + internal const string BranchPrefix = "__vc:branch:"; + internal const string TagPrefix = "__vc:tag:"; + internal const string CurrentPrefix = "__vc:current="; + internal const string AppliedPrefix = "__vc:applied="; + internal const string TransitionPrefix = "__vc:trans:"; + + private readonly TransactionsDecorator _transactions; + private readonly INamedTypesLinks _branchesStore; + private readonly object _lock = new(); + private readonly Dictionary _branches = new(StringComparer.Ordinal); + private readonly Dictionary _tags = new(StringComparer.Ordinal); + private readonly Dictionary _transitionBranches = new(); + private readonly Dictionary _branchLinks = new(StringComparer.Ordinal); + private readonly Dictionary _tagLinks = new(StringComparer.Ordinal); + private uint _currentBranchLink; + private uint _appliedLink; + private string _currentBranch = DefaultBranchName; + private long _currentApplied; + private readonly bool _trace; + + public VersionControlDecorator( + TransactionsDecorator transactions, + INamedTypesLinks branchesStore, + bool trace = false) + : base(transactions) + { + _transactions = transactions ?? throw new ArgumentNullException(nameof(transactions)); + _branchesStore = branchesStore ?? throw new ArgumentNullException(nameof(branchesStore)); + _trace = trace; + Recover(); + EnsureDefaultBranch(); + } + + public string CurrentBranch { get { lock (_lock) return _currentBranch; } } + public long CurrentSequence { get { lock (_lock) return _currentApplied; } } + + public IReadOnlyList ListBranches() + { + lock (_lock) return _branches.Values.OrderBy(b => b.Name, StringComparer.Ordinal).ToArray(); + } + + public IReadOnlyDictionary ListTags() + { + lock (_lock) return new Dictionary(_tags, StringComparer.Ordinal); + } + + public bool TryGetTag(string name, out long sequence) + { + lock (_lock) return _tags.TryGetValue(name, out sequence); + } + + // -- Write overrides (attribute new transitions to the current branch) -- + + public override uint Create(IList? substitution, WriteHandler? handler) + { + return RunVcWrite(() => _transactions.Create(substitution, handler)); + } + + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) + { + return RunVcWrite(() => _transactions.Update(restriction, substitution, handler)); + } + + public override uint Delete(IList? restriction, WriteHandler? handler) + { + return RunVcWrite(() => _transactions.Delete(restriction, handler)); + } + + private uint RunVcWrite(Func innerWrite) + { + lock (_lock) + { + var beforeSeq = _transactions.LastLoggedSequence; + var result = innerWrite(); + var afterSeq = _transactions.LastLoggedSequence; + if (afterSeq > beforeSeq) + { + for (var s = beforeSeq + 1; s <= afterSeq; s++) + { + _transitionBranches[s] = _currentBranch; + WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={_currentBranch}"); + } + if (_branches.TryGetValue(_currentBranch, out var info)) + { + var updated = info with { Head = afterSeq }; + _branches[_currentBranch] = updated; + UpdateBranchLinkLocked(updated); + } + _currentApplied = afterSeq; + SetAppliedLocked(afterSeq); + } + return result; + } + } + + // -- Branching --------------------------------------------------------- + + public void Branch(string name, long? from = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Branch name must not be empty.", nameof(name)); + } + lock (_lock) + { + if (_branches.ContainsKey(name)) + { + throw new InvalidOperationException($"Branch '{name}' already exists."); + } + var parent = _currentBranch; + var forkSeq = from ?? _currentApplied; + if (forkSeq < 0) + { + throw new ArgumentOutOfRangeException(nameof(from), forkSeq, "Fork point cannot be negative."); + } + if (forkSeq > 0) + { + var path = BuildBranchSeqsLocked(parent); + if (!path.Contains(forkSeq)) + { + throw new InvalidOperationException($"Fork point {forkSeq} is not reachable on branch '{parent}'."); + } + } + CreateBranchLocked(name, parent, forkSeq, head: forkSeq); + Trace($"Created branch '{name}' from '{parent}' at seq {forkSeq}."); + } + } + + public void SwitchBranch(string name) + { + lock (_lock) + { + if (!_branches.TryGetValue(name, out var target)) + { + throw new InvalidOperationException($"Unknown branch '{name}'."); + } + var targetPath = BuildBranchSeqsLocked(name); + ApplyDiffToLocked(targetPath, newBranch: name); + Trace($"Switched to branch '{name}' at seq {_currentApplied}."); + } + } + + public void Checkout(long sequence) + { + lock (_lock) + { + if (sequence < 0) + { + throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must be non-negative."); + } + var path = BuildBranchSeqsLocked(_currentBranch); + if (sequence > 0 && !path.Contains(sequence)) + { + throw new InvalidOperationException($"Sequence {sequence} is not reachable on branch '{_currentBranch}'."); + } + ApplyDiffToLocked(path.Where(s => s <= sequence).ToList(), newBranch: _currentBranch); + Trace($"Checked out seq {sequence} on branch '{_currentBranch}'."); + } + } + + public void Tag(string name, long? sequence = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Tag name must not be empty.", nameof(name)); + } + lock (_lock) + { + var seq = sequence ?? _currentApplied; + if (seq < 0) + { + throw new ArgumentOutOfRangeException(nameof(sequence), seq, "Tag sequence must be non-negative."); + } + _tags[name] = seq; + UpdateTagLinkLocked(name, seq); + Trace($"Created tag '{name}' at seq {seq}."); + } + } + + // -- Path / diff helpers ---------------------------------------------- + + private void ApplyDiffToLocked(List targetPath, string newBranch) + { + var currentPath = BuildBranchSeqsLocked(_currentBranch) + .Where(s => s <= _currentApplied) + .ToList(); + + var common = 0; + var max = Math.Min(currentPath.Count, targetPath.Count); + while (common < max && currentPath[common] == targetPath[common]) common++; + + for (var i = currentPath.Count - 1; i >= common; i--) + { + var transition = FindTransition(currentPath[i]); + if (transition is not null) + { + _transactions.RevertTransition(transition.Value); + } + } + for (var i = common; i < targetPath.Count; i++) + { + var transition = FindTransition(targetPath[i]); + if (transition is not null) + { + _transactions.ApplyTransition(transition.Value); + } + } + + if (!ReferenceEquals(newBranch, _currentBranch)) + { + _currentBranch = newBranch; + SetCurrentBranchLocked(newBranch); + } + _currentApplied = targetPath.Count == 0 ? 0 : targetPath[^1]; + SetAppliedLocked(_currentApplied); + } + + private List BuildBranchSeqsLocked(string branchName) + { + return BuildBranchSeqsLocked(branchName, new HashSet(StringComparer.Ordinal)); + } + + private List BuildBranchSeqsLocked(string branchName, HashSet visited) + { + if (!_branches.TryGetValue(branchName, out var info)) return new List(); + if (!visited.Add(branchName)) return new List(); + var seqs = new List(); + if (info.Parent is not null && _branches.ContainsKey(info.Parent)) + { + seqs.AddRange(BuildBranchSeqsLocked(info.Parent, visited).Where(s => s <= info.ForkSeq)); + } + var own = _transitionBranches + .Where(p => p.Value == branchName && p.Key <= info.Head) + .Select(p => p.Key) + .OrderBy(s => s); + seqs.AddRange(own); + return seqs; + } + + private Transition? FindTransition(long sequence) + { + foreach (var t in _transactions.Log) + { + if (t.Sequence == sequence) return t; + } + return null; + } + + // -- Persistence helpers ---------------------------------------------- + + private void EnsureDefaultBranch() + { + lock (_lock) + { + var existing = _transactions.LastLoggedSequence; + if (!_branches.ContainsKey(DefaultBranchName)) + { + // Pre-existing transitions are attributed to the default branch. + for (var s = 1L; s <= existing; s++) + { + if (!_transitionBranches.ContainsKey(s)) + { + _transitionBranches[s] = DefaultBranchName; + WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={DefaultBranchName}"); + } + } + CreateBranchLocked(DefaultBranchName, parent: null, forkSeq: 0, head: existing); + _currentBranch = DefaultBranchName; + _currentApplied = existing; + SetCurrentBranchLocked(DefaultBranchName); + SetAppliedLocked(existing); + } + else if (_currentBranchLink == 0) + { + SetCurrentBranchLocked(_currentBranch); + } + } + } + + private void CreateBranchLocked(string name, string? parent, long forkSeq, long head) + { + var info = new BranchInfo(name, parent, forkSeq, head); + _branches[name] = info; + UpdateBranchLinkLocked(info); + } + + private void UpdateBranchLinkLocked(BranchInfo info) + { + var nameMarker = EncodeBranchMarker(info); + if (!_branchLinks.TryGetValue(info.Name, out var link)) + { + link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _branchLinks[info.Name] = link; + } + _branchesStore.SetName(link, nameMarker); + } + + private void UpdateTagLinkLocked(string name, long seq) + { + var nameMarker = $"{TagPrefix}{name}={seq.ToString(CultureInfo.InvariantCulture)}"; + if (!_tagLinks.TryGetValue(name, out var link)) + { + link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _tagLinks[name] = link; + } + _branchesStore.SetName(link, nameMarker); + } + + private void SetCurrentBranchLocked(string name) + { + _currentBranch = name; + if (_currentBranchLink == 0) + { + _currentBranchLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + } + _branchesStore.SetName(_currentBranchLink, $"{CurrentPrefix}{name}"); + } + + private void SetAppliedLocked(long seq) + { + if (_appliedLink == 0) + { + _appliedLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + } + _branchesStore.SetName(_appliedLink, $"{AppliedPrefix}{seq.ToString(CultureInfo.InvariantCulture)}"); + } + + private void WriteImmutableMarker(string name) + { + var link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _branchesStore.SetName(link, name); + } + + private static string EncodeBranchMarker(BranchInfo info) + { + var parent = info.Parent ?? string.Empty; + return string.Concat( + BranchPrefix, + info.Name, + ":parent=", parent, + ":fork=", info.ForkSeq.ToString(CultureInfo.InvariantCulture), + ":head=", info.Head.ToString(CultureInfo.InvariantCulture)); + } + + private static bool TryDecodeBranchMarker(string text, out BranchInfo info) + { + info = default!; + if (!text.StartsWith(BranchPrefix, StringComparison.Ordinal)) return false; + var rest = text.Substring(BranchPrefix.Length); + var parentIdx = rest.IndexOf(":parent=", StringComparison.Ordinal); + if (parentIdx < 0) return false; + var name = rest.Substring(0, parentIdx); + rest = rest.Substring(parentIdx + ":parent=".Length); + var forkIdx = rest.IndexOf(":fork=", StringComparison.Ordinal); + if (forkIdx < 0) return false; + var parentText = rest.Substring(0, forkIdx); + rest = rest.Substring(forkIdx + ":fork=".Length); + var headIdx = rest.IndexOf(":head=", StringComparison.Ordinal); + if (headIdx < 0) return false; + var forkText = rest.Substring(0, headIdx); + var headText = rest.Substring(headIdx + ":head=".Length); + if (!long.TryParse(forkText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fork)) return false; + if (!long.TryParse(headText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var head)) return false; + info = new BranchInfo(name, parentText.Length == 0 ? null : parentText, fork, head); + return true; + } + + public void Recover() + { + lock (_lock) + { + _branches.Clear(); + _tags.Clear(); + _transitionBranches.Clear(); + _branchLinks.Clear(); + _tagLinks.Clear(); + _currentBranch = DefaultBranchName; + _currentBranchLink = 0; + _appliedLink = 0; + _currentApplied = 0; + + var any = _branchesStore.Constants.Any; + var anyLink = new DoubletLink(any, any, any); + foreach (var raw in _branchesStore.All(anyLink)) + { + var link = new DoubletLink(raw); + var name = _branchesStore.GetName(link.Index); + if (string.IsNullOrEmpty(name)) continue; + + if (name.StartsWith(BranchPrefix, StringComparison.Ordinal)) + { + if (TryDecodeBranchMarker(name, out var info)) + { + _branches[info.Name] = info; + _branchLinks[info.Name] = link.Index; + } + } + else if (name.StartsWith(CurrentPrefix, StringComparison.Ordinal)) + { + _currentBranch = name.Substring(CurrentPrefix.Length); + _currentBranchLink = link.Index; + } + else if (name.StartsWith(AppliedPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(AppliedPrefix.Length); + if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + _currentApplied = seq; + _appliedLink = link.Index; + } + } + else if (name.StartsWith(TagPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(TagPrefix.Length); + var eq = rest.IndexOf('='); + if (eq > 0) + { + var tagName = rest.Substring(0, eq); + if (long.TryParse(rest.Substring(eq + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var tagSeq)) + { + _tags[tagName] = tagSeq; + _tagLinks[tagName] = link.Index; + } + } + } + else if (name.StartsWith(TransitionPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(TransitionPrefix.Length); + var colon = rest.IndexOf(":branch=", StringComparison.Ordinal); + if (colon > 0 && + long.TryParse(rest.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + var branchName = rest.Substring(colon + ":branch=".Length); + _transitionBranches[seq] = branchName; + } + } + } + } + } + + // -- INamedTypes forwarding ------------------------------------------- + + public string? GetName(uint link) => _transactions.GetName(link); + public uint SetName(uint link, string name) => _transactions.SetName(link, name); + public uint GetByName(string name) => _transactions.GetByName(name); + public void RemoveName(uint link) => _transactions.RemoveName(link); + + // -- Convenience ------------------------------------------------------ + + /// Conventional sidecar filename for the version-control store. + public static string MakeVersionControlDatabaseFilename(string databaseFilename) + { + ArgumentNullException.ThrowIfNull(databaseFilename); + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.versioncontrol.links"); + } + + private void Trace(string message) + { + if (_trace) Console.WriteLine($"[VersionControl] {message}"); + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs new file mode 100644 index 0000000..7d1a617 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs @@ -0,0 +1,232 @@ +using Platform.Data; +using Platform.Data.Doublets; + +using DoubletLink = Platform.Data.Doublets.Link; + +namespace Foundation.Data.Doublets.Cli.Tests.Tests +{ + public class VersionControlDecoratorTests + { + [Fact] + public void DefaultBranchExistsOnFirstOpen() + { + RunWithVc((vc, _, _) => + { + Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); + var branches = vc.ListBranches(); + Assert.Single(branches); + Assert.Equal(VersionControlDecorator.DefaultBranchName, branches[0].Name); + }); + } + + [Fact] + public void NewTransitionsAreAttributedToCurrentBranch() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var head = tx.LastLoggedSequence; + Assert.True(head >= 2, $"CreateAndUpdate must produce at least two transitions (got {head})."); + Assert.Equal(head, vc.CurrentSequence); + }); + } + + [Fact] + public void CheckoutToZeroRewindsEverything() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + + vc.Checkout(0); + + Assert.False(vc.Exists(a), "All links must be rewound after checkout 0."); + Assert.False(vc.Exists(b)); + Assert.Equal(0, vc.CurrentSequence); + }); + } + + [Fact] + public void CheckoutAndForwardReplayRestoresState() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var afterFirst = tx.LastLoggedSequence; + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var afterSecond = tx.LastLoggedSequence; + + vc.Checkout(afterFirst); + Assert.True(vc.Exists(a), "First link must remain after partial rewind."); + Assert.False(vc.Exists(b), "Second link must disappear after partial rewind."); + + vc.Checkout(afterSecond); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b), "Second link must reappear after forward checkout."); + }); + } + + [Fact] + public void BranchForksFromCurrentHead() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var headBeforeBranch = vc.CurrentSequence; + + vc.Branch("feature"); + Assert.Contains(vc.ListBranches(), b => b.Name == "feature"); + }); + } + + [Fact] + public void SwitchBranchAppliesAndRewindsTransitions() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var headBeforeBranch = vc.CurrentSequence; + + vc.Branch("feature"); + vc.SwitchBranch("feature"); + Assert.Equal("feature", vc.CurrentBranch); + + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.True(vc.Exists(b)); + var featureHead = vc.CurrentSequence; + + vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); + Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); + Assert.True(vc.Exists(a), "Main-branch link must remain after switching back."); + Assert.False(vc.Exists(b), "Feature-branch link must disappear after switching back to main."); + Assert.Equal(headBeforeBranch, vc.CurrentSequence); + + vc.SwitchBranch("feature"); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b), "Feature-branch link must reappear after switching back to feature."); + Assert.Equal(featureHead, vc.CurrentSequence); + }); + } + + [Fact] + public void TagPointsToCurrentHead() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Tag("v1"); + Assert.True(vc.TryGetTag("v1", out var seq)); + Assert.Equal(vc.CurrentSequence, seq); + Assert.Contains("v1", vc.ListTags().Keys); + }); + } + + [Fact] + public void BranchFromExplicitSeqUsesGivenPoint() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var firstHead = vc.CurrentSequence; + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + + vc.Branch("backport", from: firstHead); + var branchInfo = vc.ListBranches().Single(b => b.Name == "backport"); + Assert.Equal(firstHead, branchInfo.ForkSeq); + }); + } + + [Fact] + public void RecoverRebuildsStateFromBranchesStore() + { + // Recovery is exercised here by attaching a *second* VC decorator + // to the same live branches store, which is equivalent in behaviour + // to reopening the underlying file (the file-mapped store is shared). + RunWithVc((vc, _, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Tag("checkpoint"); + vc.Branch("feature"); + + // Force a fresh decorator over the same in-process VC store. + var branchesStore = GetBranchesStore(vc); + var transactions = GetTransactions(vc); + var reopened = new VersionControlDecorator(transactions, branchesStore); + Assert.Contains(reopened.ListBranches(), b => b.Name == "feature"); + Assert.True(reopened.TryGetTag("checkpoint", out _)); + }); + } + + private static INamedTypesLinks GetBranchesStore(VersionControlDecorator vc) + { + return (INamedTypesLinks)typeof(VersionControlDecorator) + .GetField("_branchesStore", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(vc)!; + } + + private static TransactionsDecorator GetTransactions(VersionControlDecorator vc) + { + return (TransactionsDecorator)typeof(VersionControlDecorator) + .GetField("_transactions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(vc)!; + } + + [Fact] + public void CheckoutOutOfRangeThrows() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.Throws(() => vc.Checkout(999)); + }); + } + + [Fact] + public void DuplicateBranchThrows() + { + RunWithVc((vc, _, _) => + { + vc.Branch("feature"); + Assert.Throws(() => vc.Branch("feature")); + }); + } + + private static void RunWithVc(Action> action) + { + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + var vcFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + NamedTypesDecorator? vcLinks = null; + TransactionsDecorator? tx = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + vcLinks = new NamedTypesDecorator(vcFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + var vc = new VersionControlDecorator(tx, vcLinks); + action(vc, tx, dataLinks); + } + finally + { + tx?.Shutdown(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(vcFile); + if (dataLinks is not null) Cleanup(dataLinks.NamedLinksDatabaseFileName); + if (logLinks is not null) Cleanup(logLinks.NamedLinksDatabaseFileName); + if (vcLinks is not null) Cleanup(vcLinks.NamedLinksDatabaseFileName); + } + } + + private static void Cleanup(string path) + { + if (File.Exists(path)) File.Delete(path); + } + } +} From 8846f8d7e2dc65d57bb450fab0a073121f497998 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 13:47:14 +0000 Subject: [PATCH 07/17] feat(csharp): add CLI flags for transactions and version-control layers Adds --transactions/--transactions-file/--commit-mode/--retention flags to opt into the TransactionsDecorator layer, and --vc/--vc-file plus --branch/--branch-from/--checkout/--tag/--list-branches/--list-tags/ --log to drive the VersionControlDecorator. Layers compose on demand and remain off by default (R8 + R17: no behaviour change when not opted in). --- .../Foundation.Data.Doublets.Cli/Program.cs | 334 +++++++++++++++++- 1 file changed, 332 insertions(+), 2 deletions(-) diff --git a/csharp/Foundation.Data.Doublets.Cli/Program.cs b/csharp/Foundation.Data.Doublets.Cli/Program.cs index dd779e3..a65b64b 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Program.cs +++ b/csharp/Foundation.Data.Doublets.Cli/Program.cs @@ -106,6 +106,76 @@ Description = "Path to read and import a LiNo file into the database" }; +var transactionsOption = new Option("--transactions") +{ + Description = "Enable the transactions layer (default log path: .transitions.links)", + DefaultValueFactory = _ => false +}; + +var transactionsFileOption = new Option("--transactions-file") +{ + Description = "Path to the transitions log store (default: .transitions.links). Implies --transactions." +}; + +var commitModeOption = new Option("--commit-mode") +{ + Description = "Choose 'sync' or 'async' commits (default: sync). Implies --transactions." +}; + +var retentionOption = new Option("--retention") +{ + Description = "Log retention policy: 'infinite', 'sized:', or 'chunked::'. Implies --transactions." +}; + +var vcOption = new Option("--vc") +{ + Description = "Enable the version-control decorator (implies --transactions)", + DefaultValueFactory = _ => false +}; + +var vcFileOption = new Option("--vc-file") +{ + Description = "Path to the version-control branches store (default: .versioncontrol.links)" +}; + +var branchOption = new Option("--branch") +{ + Description = "Switch to a branch (creating it if --branch-from is also passed). Implies --vc." +}; + +var branchFromOption = new Option("--branch-from") +{ + Description = "When creating a branch with --branch, fork from this sequence point." +}; + +var checkoutOption = new Option("--checkout") +{ + Description = "Time-travel to a specific transition sequence or named tag. Implies --vc." +}; + +var tagOption = new Option("--tag") +{ + Description = "Create a tag in the form 'name' (at current head) or 'name='. Implies --vc." +}; + +var listBranchesOption = new Option("--list-branches") +{ + Description = "List version-control branches and exit.", + DefaultValueFactory = _ => false +}; + +var listTagsOption = new Option("--list-tags") +{ + Description = "List version-control tags and exit.", + DefaultValueFactory = _ => false +}; + +var logOption = new Option("--log") +{ + Description = "Print the transitions log and exit. Implies --transactions.", + DefaultValueFactory = _ => false +}; + var rootCommand = new RootCommand("LiNo CLI Tool for managing links data store"); rootCommand.Options.Add(dbOption); rootCommand.Options.Add(queryOption); @@ -124,6 +194,19 @@ rootCommand.Options.Add(embedTriggersOption); rootCommand.Options.Add(inputOption); rootCommand.Options.Add(outputOption); +rootCommand.Options.Add(transactionsOption); +rootCommand.Options.Add(transactionsFileOption); +rootCommand.Options.Add(commitModeOption); +rootCommand.Options.Add(retentionOption); +rootCommand.Options.Add(vcOption); +rootCommand.Options.Add(vcFileOption); +rootCommand.Options.Add(branchOption); +rootCommand.Options.Add(branchFromOption); +rootCommand.Options.Add(checkoutOption); +rootCommand.Options.Add(tagOption); +rootCommand.Options.Add(listBranchesOption); +rootCommand.Options.Add(listTagsOption); +rootCommand.Options.Add(logOption); rootCommand.SetAction( parseResult => @@ -145,6 +228,19 @@ var embedTriggers = parseResult.GetValue(embedTriggersOption); var inputPath = parseResult.GetValue(inputOption); var outputPath = parseResult.GetValue(outputOption); + var transactionsFlag = parseResult.GetValue(transactionsOption); + var transactionsPathRaw = parseResult.GetValue(transactionsFileOption); + var commitModeRaw = parseResult.GetValue(commitModeOption); + var retentionRaw = parseResult.GetValue(retentionOption); + var vc = parseResult.GetValue(vcOption); + var vcFile = parseResult.GetValue(vcFileOption); + var branchName = parseResult.GetValue(branchOption); + var branchFrom = parseResult.GetValue(branchFromOption); + var checkoutPoint = parseResult.GetValue(checkoutOption); + var tagSpec = parseResult.GetValue(tagOption); + var listBranches = parseResult.GetValue(listBranchesOption); + var listTags = parseResult.GetValue(listTagsOption); + var showLog = parseResult.GetValue(logOption); var triggerCommandCount = new[] { always, once, never }.Count(value => value); if (triggerCommandCount > 1) @@ -153,8 +249,91 @@ return 1; } + var vcRequested = vc + || !string.IsNullOrWhiteSpace(vcFile) + || !string.IsNullOrWhiteSpace(branchName) + || branchFrom.HasValue + || !string.IsNullOrWhiteSpace(checkoutPoint) + || !string.IsNullOrWhiteSpace(tagSpec) + || listBranches + || listTags; + + var transactionsRequested = transactionsFlag + || !string.IsNullOrWhiteSpace(transactionsPathRaw) + || !string.IsNullOrWhiteSpace(commitModeRaw) + || !string.IsNullOrWhiteSpace(retentionRaw) + || showLog + || vcRequested; + + CommitMode commitMode = CommitMode.Sync; + if (!string.IsNullOrWhiteSpace(commitModeRaw)) + { + if (commitModeRaw.Equals("sync", StringComparison.OrdinalIgnoreCase)) + { + commitMode = CommitMode.Sync; + } + else if (commitModeRaw.Equals("async", StringComparison.OrdinalIgnoreCase)) + { + commitMode = CommitMode.Async; + } + else + { + Console.Error.WriteLine($"Invalid --commit-mode value '{commitModeRaw}'. Use 'sync' or 'async'."); + return 1; + } + } + + LogRetentionPolicy? retentionPolicy = null; + if (!string.IsNullOrWhiteSpace(retentionRaw)) + { + try + { + retentionPolicy = LogRetentionPolicy.Parse(retentionRaw); + } + catch (ArgumentException ex) + { + Console.Error.WriteLine($"Invalid --retention value: {ex.Message}"); + return 1; + } + } + var baseLinks = new NamedTypesDecorator(db, trace); INamedTypesLinks decoratedLinks = baseLinks; + NamedTypesDecorator? transitionsStore = null; + NamedTypesDecorator? vcBranchesStore = null; + TransactionsDecorator? transactionsLinks = null; + VersionControlDecorator? vcLinks = null; + + if (transactionsRequested) + { + var effectiveTransactionsFile = !string.IsNullOrWhiteSpace(transactionsPathRaw) + ? transactionsPathRaw + : TransactionsDecorator.MakeTransitionsDatabaseFilename(db); + transitionsStore = new NamedTypesDecorator(effectiveTransactionsFile, trace); + transactionsLinks = new TransactionsDecorator( + baseLinks, + transitionsStore, + retentionPolicy, + commitMode, + trace); + decoratedLinks = transactionsLinks; + } + + if (vcRequested) + { + if (transactionsLinks is null) + { + Console.Error.WriteLine("--vc requires the transactions layer (this should have been auto-enabled)."); + return 1; + } + var effectiveVcFile = !string.IsNullOrWhiteSpace(vcFile) + ? vcFile + : VersionControlDecorator.MakeVersionControlDatabaseFilename(db); + vcBranchesStore = new NamedTypesDecorator(effectiveVcFile, trace); + vcLinks = new VersionControlDecorator(transactionsLinks, vcBranchesStore, trace); + decoratedLinks = vcLinks; + } + PersistentTransformationDecorator? persistentLinks = null; var defaultTriggersFile = PersistentTransformationDecorator.MakeTriggersDatabaseFilename(db); var effectiveTriggersFile = string.IsNullOrWhiteSpace(triggersFile) ? defaultTriggersFile : triggersFile; @@ -169,15 +348,165 @@ if (persistentTransformationsEnabled) { var triggerLinks = embedTriggers - ? baseLinks + ? (INamedTypesLinks)baseLinks : new NamedTypesDecorator(effectiveTriggersFile, trace); - persistentLinks = new PersistentTransformationDecorator(baseLinks, triggerLinks, trace) + persistentLinks = new PersistentTransformationDecorator(decoratedLinks, triggerLinks, trace) { AutoCreateMissingReferences = autoCreateMissingReferences }; decoratedLinks = persistentLinks; } + try + { + return RunCli(); + } + finally + { + transactionsLinks?.Shutdown(); + } + + int RunCli() + { + if (vcLinks is not null) + { + if (!string.IsNullOrWhiteSpace(checkoutPoint)) + { + if (!TryResolveSequence(vcLinks, checkoutPoint, out var seq)) + { + Console.Error.WriteLine($"Unknown checkout point '{checkoutPoint}'."); + return 1; + } + try + { + vcLinks.Checkout(seq); + if (trace) Console.WriteLine($"Checked out seq {seq} on branch '{vcLinks.CurrentBranch}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error during --checkout: {ex.Message}"); + return 1; + } + } + + if (!string.IsNullOrWhiteSpace(branchName)) + { + var existing = vcLinks.ListBranches().Any(b => b.Name == branchName); + if (!existing) + { + try + { + vcLinks.Branch(branchName, branchFrom); + if (trace) Console.WriteLine($"Created branch '{branchName}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error creating branch '{branchName}': {ex.Message}"); + return 1; + } + } + try + { + vcLinks.SwitchBranch(branchName); + if (trace) Console.WriteLine($"Switched to branch '{branchName}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error switching to branch '{branchName}': {ex.Message}"); + return 1; + } + } + + if (!string.IsNullOrWhiteSpace(tagSpec)) + { + var eq = tagSpec.IndexOf('='); + string tagName; + long? tagSeq = null; + if (eq < 0) + { + tagName = tagSpec; + } + else + { + tagName = tagSpec.Substring(0, eq); + var point = tagSpec.Substring(eq + 1); + if (!TryResolveSequence(vcLinks, point, out var resolved)) + { + Console.Error.WriteLine($"Unknown tag point '{point}'."); + return 1; + } + tagSeq = resolved; + } + try + { + vcLinks.Tag(tagName, tagSeq); + if (trace) Console.WriteLine($"Tagged '{tagName}' at seq {tagSeq ?? vcLinks.CurrentSequence}."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error creating tag '{tagName}': {ex.Message}"); + return 1; + } + } + + if (listBranches) + { + foreach (var info in vcLinks.ListBranches()) + { + var marker = info.Name == vcLinks.CurrentBranch ? "*" : " "; + var parent = info.Parent ?? "-"; + Console.WriteLine($"{marker} {info.Name}\tparent={parent}\tfork={info.ForkSeq}\thead={info.Head}"); + } + return 0; + } + + if (listTags) + { + foreach (var tag in vcLinks.ListTags().OrderBy(t => t.Key, StringComparer.Ordinal)) + { + Console.WriteLine($"{tag.Key}\t{tag.Value}"); + } + return 0; + } + } + + if (showLog) + { + if (transactionsLinks is null) + { + Console.Error.WriteLine("--log requires the transactions layer."); + return 1; + } + foreach (var transition in transactionsLinks.Log) + { + Console.WriteLine($"{transition.Sequence}\t{transition.Timestamp:O}\t{transition.Kind}\t{transition.TransactionId:N}\t({transition.Before.Index},{transition.Before.Source},{transition.Before.Target}) -> ({transition.After.Index},{transition.After.Source},{transition.After.Target})"); + } + return 0; + } + + return RunQueryPipeline(); + } + + bool TryResolveSequence(VersionControlDecorator vc, string point, out long sequence) + { + sequence = 0; + if (string.IsNullOrWhiteSpace(point)) return false; + if (long.TryParse(point, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var direct)) + { + sequence = direct; + return true; + } + if (vc.TryGetTag(point, out var tagSeq)) + { + sequence = tagSeq; + return true; + } + return false; + } + + int RunQueryPipeline() + { + if (before) { PrintAllLinks(decoratedLinks); @@ -279,6 +608,7 @@ } return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } } ); From 3ce75bb3bf62be1f8ca4e0c3c3150d01633d85bf Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 13:57:41 +0000 Subject: [PATCH 08/17] feat(rust): add TransactionsDecorator with reversible transitions log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the C# `TransactionsDecorator`: wraps a `NamedTypesDecorator` and records every create/update/delete as a reversible `Transition` in a sidecar log store. Supports explicit transactions with commit/rollback, two commit modes, three retention policies (infinite/sized/chunked) and crash recovery (auto-rollback of orphaned transitions + replay of committed-but-unapplied transitions). Decorator is opt-in — bare `NamedTypesDecorator` behaves identically. Adds 14 integration tests mirroring the C# `TransactionsDecoratorTests` plus 2 unit tests inside the module. --- rust/src/lib.rs | 5 + rust/src/transactions/mod.rs | 992 +++++++++++++++++++++ rust/tests/transactions_decorator_tests.rs | 301 +++++++ 3 files changed, 1298 insertions(+) create mode 100644 rust/src/transactions/mod.rs create mode 100644 rust/tests/transactions_decorator_tests.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index a90bcdb..36c01fe 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -33,6 +33,7 @@ mod query_processor; mod query_processor_substitution; mod query_types; pub mod sequences; +pub mod transactions; mod unicode_string_storage; // Re-export main types for easy access @@ -50,4 +51,8 @@ pub use parser::Parser; pub use pinned_types::{PinnedTypes, PinnedTypesAccess, PinnedTypesDecorator}; pub use query_options::QueryOptions; pub use query_processor::QueryProcessor; +pub use transactions::{ + CommitMode, DoubletLink, LogRetentionPolicy, TransactionHandle, TransactionsDecorator, + Transition, TransitionKind, +}; pub use unicode_string_storage::UnicodeStringStorage; diff --git a/rust/src/transactions/mod.rs b/rust/src/transactions/mod.rs new file mode 100644 index 0000000..025afed --- /dev/null +++ b/rust/src/transactions/mod.rs @@ -0,0 +1,992 @@ +//! Optional transactions layer for the Rust link-cli. +//! +//! Mirrors the C# `TransactionsDecorator` in +//! `csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs`. +//! +//! The decorator wraps a [`NamedTypesDecorator`] and records every +//! `create` / `update` / `delete` as a reversible [`Transition`] in a +//! sidecar doublets log store. Supports explicit transactions, sync +//! commits, three retention policies, and crash recovery (R1-R7, R10). +//! +//! Optional — when not opted in, the bare [`NamedTypesDecorator`] +//! behaves identically (R8, R9, R17). + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context, Result}; + +use crate::link::Link; +use crate::named_types::{NamedTypes, NamedTypesDecorator}; + +/// The kind of write operation recorded by a [`Transition`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TransitionKind { + Create, + Update, + Delete, +} + +impl TransitionKind { + fn as_u8(self) -> u8 { + match self { + TransitionKind::Create => 0, + TransitionKind::Update => 1, + TransitionKind::Delete => 2, + } + } + + fn from_u8(value: u8) -> Option { + match value { + 0 => Some(TransitionKind::Create), + 1 => Some(TransitionKind::Update), + 2 => Some(TransitionKind::Delete), + _ => None, + } + } +} + +/// Sync flushes data-store side-effects before `commit` returns. +/// +/// Async durably persists the transitions then applies the data-store +/// side-effects on a background-friendly path (already-applied +/// side-effects are the common case for in-process inner stores). +/// +/// The Rust port runs both modes synchronously on the calling thread +/// for predictability; the distinction is preserved for parity with C# +/// and for future expansion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CommitMode { + #[default] + Sync, + Async, +} + +/// Retention policy for the transitions log. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogRetentionPolicy { + /// Keep every transition forever (default). + Infinite, + /// Drop the oldest applied transitions once the live log exceeds + /// `max_transitions`. Never drops un-applied transitions (R7). + Sized { max_transitions: u64 }, + /// Archive the oldest `chunk_size` applied transitions to a + /// rolling file in `archive_directory` once the live log reaches + /// `chunk_size`. + Chunked { + chunk_size: u64, + archive_directory: PathBuf, + }, +} + +impl Default for LogRetentionPolicy { + fn default() -> Self { + Self::Infinite + } +} + +impl LogRetentionPolicy { + /// Parses a CLI spec: `infinite`, `sized:`, `chunked::`. + pub fn parse(spec: &str) -> Result { + let trimmed = spec.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("infinite") { + return Ok(Self::Infinite); + } + + let lowered = trimmed.to_ascii_lowercase(); + if lowered.starts_with("sized:") { + let rest = &trimmed["sized:".len()..]; + let max: u64 = rest + .parse() + .map_err(|_| anyhow!("invalid sized retention spec '{spec}'"))?; + return Ok(Self::Sized { + max_transitions: max, + }); + } + if lowered.starts_with("chunked:") { + let rest = &trimmed["chunked:".len()..]; + let (size_text, dir) = rest + .split_once(':') + .ok_or_else(|| anyhow!("invalid chunked retention spec '{spec}'"))?; + let chunk_size: u64 = size_text + .parse() + .map_err(|_| anyhow!("invalid chunked size in '{spec}'"))?; + if chunk_size == 0 { + bail!("invalid chunked size in '{spec}'"); + } + if dir.is_empty() { + bail!("invalid chunked retention spec '{spec}'"); + } + return Ok(Self::Chunked { + chunk_size, + archive_directory: PathBuf::from(dir), + }); + } + bail!("unknown retention spec '{spec}'"); + } +} + +/// A single doublet link state captured by a transition (mirror of the +/// C# `Platform.Data.Doublets.Link`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] +pub struct DoubletLink { + pub index: u32, + pub source: u32, + pub target: u32, +} + +impl DoubletLink { + pub const fn new(index: u32, source: u32, target: u32) -> Self { + Self { + index, + source, + target, + } + } + + pub const fn empty() -> Self { + Self::new(0, 0, 0) + } + + pub fn from_link(link: &Link) -> Self { + Self::new(link.index, link.source, link.target) + } +} + +/// Reversible write captured by the transactions layer. Holds both +/// `before` and `after` link states so the operation can be undone or +/// replayed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Transition { + pub transaction_id: u128, + pub sequence: i64, + pub timestamp_ms: i64, + pub kind: TransitionKind, + pub before: DoubletLink, + pub after: DoubletLink, +} + +impl Transition { + pub(crate) const SCHEMA_VERSION: &'static str = "v1"; + + /// Encodes the transition as a single line stored as the *name* + /// of one link in the log doublets store. + pub fn serialize(&self) -> String { + format!( + "{schema}|{tx:032x}|{seq}|{ms}|{kind}|{bi},{bs},{bt}|{ai},{as_},{at}", + schema = Self::SCHEMA_VERSION, + tx = self.transaction_id, + seq = self.sequence, + ms = self.timestamp_ms, + kind = self.kind.as_u8(), + bi = self.before.index, + bs = self.before.source, + bt = self.before.target, + ai = self.after.index, + as_ = self.after.source, + at = self.after.target, + ) + } + + /// Parses a serialized transition. + pub fn try_parse(text: &str) -> Option { + if text.is_empty() { + return None; + } + let parts: Vec<&str> = text.split('|').collect(); + if parts.len() < 7 { + return None; + } + if parts[0] != Self::SCHEMA_VERSION { + return None; + } + let tx = u128::from_str_radix(parts[1], 16).ok()?; + let seq: i64 = parts[2].parse().ok()?; + let ms: i64 = parts[3].parse().ok()?; + let kind_value: u8 = parts[4].parse().ok()?; + let kind = TransitionKind::from_u8(kind_value)?; + let before = parse_doublet(parts[5])?; + let after = parse_doublet(parts[6])?; + Some(Self { + transaction_id: tx, + sequence: seq, + timestamp_ms: ms, + kind, + before, + after, + }) + } +} + +fn parse_doublet(text: &str) -> Option { + let parts: Vec<&str> = text.split(',').collect(); + if parts.len() != 3 { + return None; + } + Some(DoubletLink { + index: parts[0].parse().ok()?, + source: parts[1].parse().ok()?, + target: parts[2].parse().ok()?, + }) +} + +/// Sidecar-store name prefixes used by the recovery protocol. +pub(crate) const COMMIT_MARKER_PREFIX: &str = "__transactions:commit:"; +pub(crate) const ROLLBACK_MARKER_PREFIX: &str = "__transactions:rollback:"; +pub(crate) const APPLIED_MARKER_PREFIX: &str = "__transactions:applied:"; +pub(crate) const TRANSITION_NAME_PREFIX: &str = "__transactions:transition:"; + +/// Pending state of a transaction (used by the explicit transaction +/// handle and by per-write auto-transactions). +struct PendingTransaction { + id: u128, + transitions: Vec, + auto_commit: bool, + started_ms: i64, +} + +/// Snapshot of an open transaction (returned by [`TransactionsDecorator::begin_transaction`]). +#[derive(Debug, Clone)] +pub struct TransactionHandle { + pub id: u128, + pub started_ms: i64, +} + +/// The transactions decorator wraps a [`NamedTypesDecorator`] and +/// records every write as a reversible [`Transition`] in `log_store`. +pub struct TransactionsDecorator { + inner: NamedTypesDecorator, + log_store: NamedTypesDecorator, + log: Vec, + committed: HashSet, + rolled_back: HashSet, + applied: HashSet, + current: Option, + sequence_counter: i64, + applied_sequence: i64, + retention_policy: LogRetentionPolicy, + commit_mode: CommitMode, + replaying: bool, + trace: bool, +} + +impl TransactionsDecorator { + /// Creates a new transactions decorator wrapping `inner`, using + /// `log_store` as the sidecar log store. + pub fn new( + inner: NamedTypesDecorator, + log_store: NamedTypesDecorator, + retention_policy: LogRetentionPolicy, + commit_mode: CommitMode, + trace: bool, + ) -> Result { + let mut decorator = Self { + inner, + log_store, + log: Vec::new(), + committed: HashSet::new(), + rolled_back: HashSet::new(), + applied: HashSet::new(), + current: None, + sequence_counter: 0, + applied_sequence: 0, + retention_policy, + commit_mode, + replaying: false, + trace, + }; + decorator.recover()?; + Ok(decorator) + } + + /// Conventional sidecar filename for the transitions log. + pub fn make_transitions_database_filename>(database_filename: P) -> PathBuf { + let path = database_filename.as_ref(); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let name = format!("{stem}.transitions.links"); + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(name), + _ => PathBuf::from(name), + } + } + + pub fn retention_policy(&self) -> &LogRetentionPolicy { + &self.retention_policy + } + + pub fn set_retention_policy(&mut self, policy: LogRetentionPolicy) { + self.retention_policy = policy; + } + + pub fn commit_mode(&self) -> CommitMode { + self.commit_mode + } + + pub fn set_commit_mode(&mut self, mode: CommitMode) { + self.commit_mode = mode; + } + + pub fn applied_sequence(&self) -> i64 { + self.applied_sequence + } + + pub fn last_logged_sequence(&self) -> i64 { + self.sequence_counter + } + + /// Returns a snapshot of the transitions log in sequence order. + pub fn log(&self) -> Vec { + self.log.clone() + } + + pub fn inner(&self) -> &NamedTypesDecorator { + &self.inner + } + + pub fn inner_mut(&mut self) -> &mut NamedTypesDecorator { + &mut self.inner + } + + pub fn log_store(&self) -> &NamedTypesDecorator { + &self.log_store + } + + pub fn log_store_mut(&mut self) -> &mut NamedTypesDecorator { + &mut self.log_store + } + + pub fn into_inner(self) -> (NamedTypesDecorator, NamedTypesDecorator) { + (self.inner, self.log_store) + } + + pub fn save(&self) -> Result<()> { + self.inner.save()?; + self.log_store.save()?; + Ok(()) + } + + // ----- Write API ------------------------------------------------------ + + pub fn create(&mut self, source: u32, target: u32) -> Result { + if self.replaying { + return Ok(self.inner.create(source, target)); + } + let owns = self.ensure_open_transaction(); + let id = self.inner.create(source, target); + let after = self + .inner + .get(id) + .map(DoubletLink::from_link) + .unwrap_or_else(|| DoubletLink::new(id, source, target)); + self.record_transition(TransitionKind::Create, DoubletLink::empty(), after)?; + if owns { + self.commit_current()?; + } + Ok(id) + } + + pub fn update(&mut self, id: u32, source: u32, target: u32) -> Result { + if self.replaying { + return self.inner.update(id, source, target); + } + let before = self + .inner + .get(id) + .map(DoubletLink::from_link) + .unwrap_or_else(|| DoubletLink::new(id, 0, 0)); + let owns = self.ensure_open_transaction(); + let prev = match self.inner.update(id, source, target) { + Ok(prev) => prev, + Err(err) => { + if owns { + self.current = None; + } + return Err(err); + } + }; + let after = self + .inner + .get(id) + .map(DoubletLink::from_link) + .unwrap_or_else(|| DoubletLink::new(id, source, target)); + self.record_transition(TransitionKind::Update, before, after)?; + if owns { + self.commit_current()?; + } + Ok(prev) + } + + pub fn delete(&mut self, id: u32) -> Result { + if self.replaying { + return self.inner.delete(id); + } + let before = self + .inner + .get(id) + .map(DoubletLink::from_link) + .unwrap_or_else(|| DoubletLink::new(id, 0, 0)); + let owns = self.ensure_open_transaction(); + let deleted = match self.inner.delete(id) { + Ok(d) => d, + Err(err) => { + if owns { + self.current = None; + } + return Err(err); + } + }; + self.record_transition(TransitionKind::Delete, before, DoubletLink::empty())?; + if owns { + self.commit_current()?; + } + Ok(deleted) + } + + /// Composite create-and-update used by callers that want a link + /// initialised with source/target in a single pair of transitions + /// (matches the C# `CreateAndUpdate` extension semantics, which + /// always emits a Create followed by an Update transition). + pub fn create_and_update(&mut self, source: u32, target: u32) -> Result { + let owns = self.ensure_open_transaction(); + let id = self.create(0, 0)?; + self.update(id, source, target)?; + if owns { + self.commit_current()?; + } + Ok(id) + } + + pub fn exists(&self, id: u32) -> bool { + self.inner.exists(id) + } + + pub fn get(&self, id: u32) -> Option<&Link> { + self.inner.get(id) + } + + pub fn all(&self) -> Vec<&Link> { + self.inner.all() + } + + pub fn query( + &self, + index: Option, + source: Option, + target: Option, + ) -> Vec<&Link> { + self.inner.query(index, source, target) + } + + fn ensure_open_transaction(&mut self) -> bool { + if self.current.is_none() { + self.current = Some(PendingTransaction { + id: new_transaction_id(), + transitions: Vec::new(), + auto_commit: true, + started_ms: now_unix_ms(), + }); + true + } else { + false + } + } + + fn record_transition( + &mut self, + kind: TransitionKind, + before: DoubletLink, + after: DoubletLink, + ) -> Result<()> { + self.sequence_counter += 1; + let sequence = self.sequence_counter; + let timestamp_ms = now_unix_ms(); + let transaction_id = self + .current + .as_ref() + .map(|tx| tx.id) + .ok_or_else(|| anyhow!("internal: missing open transaction while recording transition"))?; + let transition = Transition { + transaction_id, + sequence, + timestamp_ms, + kind, + before, + after, + }; + if let Some(current) = self.current.as_mut() { + current.transitions.push(transition); + } + self.log.push(transition); + self.write_transition_to_log(&transition)?; + if self.trace { + eprintln!( + "[Transactions] Recorded {:?} seq={} tx={:032x}: ({},{},{}) -> ({},{},{}).", + kind, + sequence, + transaction_id, + before.index, + before.source, + before.target, + after.index, + after.source, + after.target, + ); + } + Ok(()) + } + + fn write_transition_to_log(&mut self, transition: &Transition) -> Result<()> { + // Always allocate a fresh link so each transition has its own + // log entry (mirrors C# `CreateAndUpdate(Null, Null)`). + let link = self.log_store.create(0, 0); + let name = format!("{TRANSITION_NAME_PREFIX}{}", transition.serialize()); + self.log_store.set_name(link, &name)?; + Ok(()) + } + + fn write_marker(&mut self, name: &str) -> Result<()> { + // Always allocate a fresh link so markers do not overwrite one + // another (mirrors C# `CreateAndUpdate(Null, Null)`). + let link = self.log_store.create(0, 0); + self.log_store.set_name(link, name)?; + Ok(()) + } + + // ----- Transaction handle -------------------------------------------- + + pub fn begin_transaction(&mut self) -> Result { + if self.current.is_some() { + bail!("Nested transactions are not supported."); + } + let id = new_transaction_id(); + let started_ms = now_unix_ms(); + self.current = Some(PendingTransaction { + id, + transitions: Vec::new(), + auto_commit: false, + started_ms, + }); + Ok(TransactionHandle { id, started_ms }) + } + + pub fn commit(&mut self) -> Result<()> { + if self.current.is_none() { + return Ok(()); + } + self.commit_current() + } + + fn commit_current(&mut self) -> Result<()> { + let pending = match self.current.take() { + Some(p) => p, + None => return Ok(()), + }; + self.committed.insert(pending.id); + self.write_marker(&format!("{COMMIT_MARKER_PREFIX}{:032x}", pending.id))?; + if self.trace { + eprintln!( + "[Transactions] Committed tx {:032x} (mode={:?}, transitions={}).", + pending.id, + self.commit_mode, + pending.transitions.len() + ); + } + for transition in &pending.transitions { + self.mark_applied(transition)?; + } + let _ = pending.auto_commit; + let _ = pending.started_ms; + self.enforce_retention()?; + Ok(()) + } + + pub fn rollback(&mut self) -> Result<()> { + let pending = match self.current.take() { + Some(p) => p, + None => return Ok(()), + }; + self.rolled_back.insert(pending.id); + self.replaying = true; + for transition in pending.transitions.iter().rev() { + self.try_revert_transition(transition); + } + self.replaying = false; + self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{:032x}", pending.id))?; + if self.trace { + eprintln!( + "[Transactions] Rolled back tx {:032x} ({} transitions).", + pending.id, + pending.transitions.len(), + ); + } + self.enforce_retention()?; + Ok(()) + } + + /// Public helper for higher-level decorators (e.g. version control) + /// — applies a single transition without writing a new log entry. + pub fn apply_transition(&mut self, transition: &Transition) { + self.replaying = true; + self.try_apply_transition(transition, false); + self.replaying = false; + } + + /// Public helper for higher-level decorators (e.g. version control) + /// — reverts a single transition without writing a new log entry. + pub fn revert_transition(&mut self, transition: &Transition) { + self.replaying = true; + self.try_revert_transition(transition); + self.replaying = false; + } + + fn try_apply_transition(&mut self, transition: &Transition, record_applied: bool) { + let result: Result<()> = match transition.kind { + TransitionKind::Create => { + if transition.after.index != 0 && !self.inner.exists(transition.after.index) { + self.inner.ensure_created(transition.after.index); + self.inner + .update( + transition.after.index, + transition.after.source, + transition.after.target, + ) + .map(|_| ()) + } else { + Ok(()) + } + } + TransitionKind::Update => { + if transition.after.index != 0 && self.inner.exists(transition.after.index) { + self.inner + .update( + transition.after.index, + transition.after.source, + transition.after.target, + ) + .map(|_| ()) + } else { + Ok(()) + } + } + TransitionKind::Delete => { + if transition.before.index != 0 && self.inner.exists(transition.before.index) { + self.inner.delete(transition.before.index).map(|_| ()) + } else { + Ok(()) + } + } + }; + if let Err(e) = result { + if self.trace { + eprintln!( + "[Transactions] Failed to apply transition seq={}: {e}", + transition.sequence + ); + } + } + if record_applied { + let _ = self.mark_applied(transition); + } + } + + fn try_revert_transition(&mut self, transition: &Transition) { + let result = match transition.kind { + TransitionKind::Create => { + if transition.after.index != 0 && self.inner.exists(transition.after.index) { + self.inner.delete(transition.after.index).map(|_| ()) + } else { + Ok(()) + } + } + TransitionKind::Update => { + if transition.before.index != 0 && self.inner.exists(transition.before.index) { + self.inner + .update( + transition.before.index, + transition.before.source, + transition.before.target, + ) + .map(|_| ()) + } else { + Ok(()) + } + } + TransitionKind::Delete => { + if transition.before.index != 0 && !self.inner.exists(transition.before.index) { + self.inner.ensure_created(transition.before.index); + self.inner + .update( + transition.before.index, + transition.before.source, + transition.before.target, + ) + .map(|_| ()) + } else { + Ok(()) + } + } + }; + if let Err(e) = result { + if self.trace { + eprintln!( + "[Transactions] Failed to revert transition seq={}: {e}", + transition.sequence + ); + } + } + } + + fn mark_applied(&mut self, transition: &Transition) -> Result<()> { + if self.applied.insert(transition.sequence) { + self.write_marker(&format!( + "{APPLIED_MARKER_PREFIX}{}", + transition.sequence + ))?; + if transition.sequence > self.applied_sequence { + self.applied_sequence = transition.sequence; + } + } + Ok(()) + } + + // ----- Recovery ------------------------------------------------------- + + /// Rebuilds the in-memory log and marker tables from the sidecar + /// log store and re-applies committed-but-unapplied side-effects. + pub fn recover(&mut self) -> Result<()> { + self.log.clear(); + self.committed.clear(); + self.rolled_back.clear(); + self.applied.clear(); + self.sequence_counter = 0; + self.applied_sequence = 0; + + // Read every named link from the log store. + let all_links: Vec = self + .log_store + .all() + .into_iter() + .copied() + .collect(); + for link in &all_links { + let name = match self.log_store.get_name(link.index)? { + Some(value) => value, + None => continue, + }; + if let Some(payload) = name.strip_prefix(TRANSITION_NAME_PREFIX) { + if let Some(transition) = Transition::try_parse(payload) { + insert_ordered(&mut self.log, transition); + if transition.sequence > self.sequence_counter { + self.sequence_counter = transition.sequence; + } + } + } else if let Some(rest) = name.strip_prefix(COMMIT_MARKER_PREFIX) { + if let Ok(tx_id) = u128::from_str_radix(rest, 16) { + self.committed.insert(tx_id); + } + } else if let Some(rest) = name.strip_prefix(ROLLBACK_MARKER_PREFIX) { + if let Ok(tx_id) = u128::from_str_radix(rest, 16) { + self.rolled_back.insert(tx_id); + } + } else if let Some(rest) = name.strip_prefix(APPLIED_MARKER_PREFIX) { + if let Ok(seq) = rest.parse::() { + self.applied.insert(seq); + if seq > self.applied_sequence { + self.applied_sequence = seq; + } + } + } + } + + // Re-apply committed-but-not-applied transitions (crash mid-async). + let log_snapshot: Vec = self.log.clone(); + self.replaying = true; + for transition in &log_snapshot { + if !self.committed.contains(&transition.transaction_id) { + continue; + } + if self.applied.contains(&transition.sequence) { + continue; + } + self.try_apply_transition(transition, true); + } + // Auto-rollback transitions written but never committed and never rolled back (R10). + let mut pending_tx_ids: Vec = Vec::new(); + for transition in log_snapshot.iter().rev() { + if self.committed.contains(&transition.transaction_id) { + continue; + } + if self.rolled_back.contains(&transition.transaction_id) { + continue; + } + self.try_revert_transition(transition); + if !pending_tx_ids.contains(&transition.transaction_id) { + pending_tx_ids.push(transition.transaction_id); + } + } + self.replaying = false; + for tx_id in pending_tx_ids { + self.rolled_back.insert(tx_id); + self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{tx_id:032x}"))?; + } + Ok(()) + } + + fn enforce_retention(&mut self) -> Result<()> { + match self.retention_policy.clone() { + LogRetentionPolicy::Infinite => Ok(()), + LogRetentionPolicy::Sized { max_transitions } => self.enforce_sized(max_transitions), + LogRetentionPolicy::Chunked { + chunk_size, + archive_directory, + } => self.enforce_chunked(chunk_size, &archive_directory), + } + } + + fn enforce_sized(&mut self, max_transitions: u64) -> Result<()> { + if max_transitions == 0 { + return Ok(()); + } + while self.log.len() as u64 > max_transitions { + let head = self.log[0]; + if !self.applied.contains(&head.sequence) { + self.replaying = true; + self.try_apply_transition(&head, true); + self.replaying = false; + if !self.applied.contains(&head.sequence) { + break; // R7: never drop an un-applied transition. + } + } + self.log.remove(0); + if self.trace { + eprintln!( + "[Transactions] Dropped applied transition seq={} per sized retention.", + head.sequence + ); + } + } + Ok(()) + } + + fn enforce_chunked(&mut self, chunk_size: u64, archive_directory: &Path) -> Result<()> { + if chunk_size == 0 { + return Ok(()); + } + if (self.log.len() as u64) < chunk_size { + return Ok(()); + } + let chunk: Vec = self.log.iter().take(chunk_size as usize).copied().collect(); + for transition in &chunk { + if !self.applied.contains(&transition.sequence) { + self.replaying = true; + self.try_apply_transition(transition, true); + self.replaying = false; + if !self.applied.contains(&transition.sequence) { + return Ok(()); // never drop un-applied + } + } + } + std::fs::create_dir_all(archive_directory) + .with_context(|| format!("failed to create archive dir {}", archive_directory.display()))?; + let timestamp = now_unix_ms(); + let file_name = + format!("transitions-chunk-{timestamp}-{:032x}.log", new_transaction_id()); + let path = archive_directory.join(file_name); + use std::io::Write; + let mut file = std::fs::File::create(&path) + .with_context(|| format!("failed to create archive file {}", path.display()))?; + for transition in &chunk { + writeln!(file, "{}", transition.serialize())?; + } + file.flush()?; + if self.trace { + eprintln!( + "[Transactions] Archived {} transitions to {}.", + chunk.len(), + path.display() + ); + } + self.log.drain(0..chunk.len()); + Ok(()) + } +} + +// ----- Helpers ---------------------------------------------------------- + +fn insert_ordered(list: &mut Vec, transition: Transition) { + let mut lo = 0usize; + let mut hi = list.len(); + while lo < hi { + let mid = (lo + hi) / 2; + if list[mid].sequence < transition.sequence { + lo = mid + 1; + } else { + hi = mid; + } + } + list.insert(lo, transition); +} + +static TX_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn new_transaction_id() -> u128 { + // Combine a per-process counter with the current timestamp to + // approximate a Guid without pulling in the `uuid` crate. + let count = TX_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; + let now = now_unix_ms() as u128; + (now << 64) | count +} + +fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retention_policy_parses_specs() { + assert!(matches!( + LogRetentionPolicy::parse("infinite").unwrap(), + LogRetentionPolicy::Infinite + )); + assert!(matches!( + LogRetentionPolicy::parse("sized:1000").unwrap(), + LogRetentionPolicy::Sized { max_transitions: 1000 } + )); + match LogRetentionPolicy::parse("chunked:500:/tmp/x").unwrap() { + LogRetentionPolicy::Chunked { + chunk_size, + archive_directory, + } => { + assert_eq!(chunk_size, 500); + assert_eq!(archive_directory, PathBuf::from("/tmp/x")); + } + _ => panic!("expected Chunked"), + } + assert!(LogRetentionPolicy::parse("garbage").is_err()); + } + + #[test] + fn transition_round_trips_through_serialize() { + let t = Transition { + transaction_id: 0xabcdef1234567890u128, + sequence: 42, + timestamp_ms: 1234567890, + kind: TransitionKind::Update, + before: DoubletLink::new(1, 2, 3), + after: DoubletLink::new(1, 4, 5), + }; + let parsed = Transition::try_parse(&t.serialize()).unwrap(); + assert_eq!(t, parsed); + } +} diff --git a/rust/tests/transactions_decorator_tests.rs b/rust/tests/transactions_decorator_tests.rs new file mode 100644 index 0000000..ac24253 --- /dev/null +++ b/rust/tests/transactions_decorator_tests.rs @@ -0,0 +1,301 @@ +//! Integration tests for the optional transactions decorator (Rust side). +//! +//! Mirrors the C# `TransactionsDecoratorTests` and exercises the +//! requirements R1–R10 of issue #94. + +use anyhow::Result; +use link_cli::transactions::DoubletLink; +use link_cli::{ + CommitMode, LogRetentionPolicy, NamedTypesDecorator, TransactionsDecorator, Transition, + TransitionKind, +}; +use std::path::PathBuf; +use tempfile::NamedTempFile; + +fn make_tx() -> (TransactionsDecorator, Vec) { + let data_file = NamedTempFile::new().expect("create temp file"); + let log_file = NamedTempFile::new().expect("create temp file"); + let data_links = + NamedTypesDecorator::new(data_file.path(), false).expect("open data links"); + let log_links = NamedTypesDecorator::new(log_file.path(), false).expect("open log links"); + let tx = TransactionsDecorator::new( + data_links, + log_links, + LogRetentionPolicy::default(), + CommitMode::default(), + false, + ) + .expect("open transactions decorator"); + // Keep the temp files alive for the duration of the test. + (tx, vec![data_file, log_file]) +} + +#[test] +fn auto_transaction_records_create_and_update() -> Result<()> { + let (mut tx, _guards) = make_tx(); + + let created = tx.create_and_update(0, 0)?; + assert_ne!(0, created); + + let log = tx.log(); + assert_eq!(2, log.len(), "create_and_update must record two transitions"); + assert_eq!(TransitionKind::Create, log[0].kind); + assert_eq!(TransitionKind::Update, log[1].kind); + assert_eq!(created, log[0].after.index); + Ok(()) +} + +#[test] +fn rollback_undoes_create() -> Result<()> { + let (mut tx, _guards) = make_tx(); + + tx.begin_transaction()?; + let created = tx.create_and_update(0, 0)?; + assert!(tx.exists(created)); + tx.rollback()?; + + assert!(!tx.exists(created), "rolled-back create must remove the link"); + Ok(()) +} + +#[test] +fn commit_persists_create() -> Result<()> { + let (mut tx, _guards) = make_tx(); + + tx.begin_transaction()?; + let created = tx.create_and_update(0, 0)?; + tx.commit()?; + + assert!(tx.exists(created)); + assert_eq!(tx.last_logged_sequence(), tx.applied_sequence()); + Ok(()) +} + +#[test] +fn rollback_undoes_update() -> Result<()> { + let (mut tx, _guards) = make_tx(); + + let a = tx.create_and_update(0, 0)?; + let b = tx.create_and_update(0, 0)?; + let c = tx.create_and_update(0, 0)?; + + tx.begin_transaction()?; + tx.update(c, a, b)?; + let updated = tx.get(c).copied().unwrap(); + assert_eq!(a, updated.source); + assert_eq!(b, updated.target); + tx.rollback()?; + + let after_rollback = tx.get(c).copied().unwrap(); + assert_eq!(c, after_rollback.index); + assert_eq!(0, after_rollback.source); + assert_eq!(0, after_rollback.target); + Ok(()) +} + +#[test] +fn rollback_undoes_delete() -> Result<()> { + let (mut tx, _guards) = make_tx(); + + let a = tx.create_and_update(0, 0)?; + let b = tx.create_and_update(0, 0)?; + let c = tx.create_and_update(0, 0)?; + tx.update(c, a, b)?; + + tx.begin_transaction()?; + tx.delete(c)?; + assert!(!tx.exists(c)); + tx.rollback()?; + + assert!(tx.exists(c), "delete must be restored by rollback"); + let restored = tx.get(c).copied().unwrap(); + assert_eq!(a, restored.source); + assert_eq!(b, restored.target); + Ok(()) +} + +#[test] +fn sized_retention_drops_oldest_after_applied() -> Result<()> { + let (mut tx, _guards) = make_tx(); + tx.set_retention_policy(LogRetentionPolicy::Sized { + max_transitions: 3, + }); + + for _ in 0..5 { + tx.create_and_update(0, 0)?; + } + + assert!( + tx.log().len() as u64 <= 3, + "sized retention must cap log length; got {}", + tx.log().len() + ); + Ok(()) +} + +#[test] +fn chunked_retention_archives_oldest() -> Result<()> { + let archive_dir = std::env::temp_dir().join(format!( + "tx-archive-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&archive_dir); + + { + let (mut tx, _guards) = make_tx(); + tx.set_retention_policy(LogRetentionPolicy::Chunked { + chunk_size: 2, + archive_directory: archive_dir.clone(), + }); + + for _ in 0..4 { + tx.create_and_update(0, 0)?; + } + + assert!(archive_dir.exists(), "archive directory must be created"); + let files: Vec<_> = std::fs::read_dir(&archive_dir)? + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with("transitions-chunk-") + }) + .collect(); + assert!(!files.is_empty(), "chunked retention must archive files"); + } + let _ = std::fs::remove_dir_all(&archive_dir); + Ok(()) +} + +#[test] +fn retention_policy_parses_specs() { + assert!(matches!( + LogRetentionPolicy::parse("infinite").unwrap(), + LogRetentionPolicy::Infinite + )); + assert!(matches!( + LogRetentionPolicy::parse("sized:1000").unwrap(), + LogRetentionPolicy::Sized { + max_transitions: 1000 + } + )); + match LogRetentionPolicy::parse("chunked:500:/tmp/x").unwrap() { + LogRetentionPolicy::Chunked { + chunk_size, + archive_directory, + } => { + assert_eq!(chunk_size, 500); + assert_eq!(archive_directory, PathBuf::from("/tmp/x")); + } + _ => panic!("expected Chunked"), + } + assert!(LogRetentionPolicy::parse("garbage").is_err()); +} + +#[test] +fn transition_round_trips_through_serialize() { + let t = Transition { + transaction_id: 0xabcdef1234567890u128, + sequence: 42, + timestamp_ms: 1234567890, + kind: TransitionKind::Update, + before: DoubletLink::new(1, 2, 3), + after: DoubletLink::new(1, 4, 5), + }; + + let parsed = Transition::try_parse(&t.serialize()).unwrap(); + assert_eq!(t, parsed); +} + +#[test] +fn async_commit_marks_applied() -> Result<()> { + // Rust port runs sync; the contract that `applied_sequence` reaches + // `last_logged_sequence` after commit still holds. + let (mut tx, _guards) = make_tx(); + tx.set_commit_mode(CommitMode::Async); + + tx.begin_transaction()?; + let created = tx.create_and_update(0, 0)?; + tx.commit()?; + + assert_eq!(tx.last_logged_sequence(), tx.applied_sequence()); + assert!(tx.exists(created)); + Ok(()) +} + +#[test] +fn no_behaviour_change_when_not_opted_in() -> Result<()> { + // R8: bare NamedTypesDecorator behaves identically whether or not + // the TransactionsDecorator is wrapped above it. + let data_file = NamedTempFile::new()?; + let mut data_links = NamedTypesDecorator::new(data_file.path(), false)?; + let id = data_links.get_or_create(0, 0); + assert!(data_links.exists(id)); + Ok(()) +} + +#[test] +fn recovery_reapplies_committed_transitions() -> Result<()> { + // After dropping the decorator and reopening with the same store + // files, the recovery protocol should rebuild the log and restore + // committed state (R6). + let data_file = NamedTempFile::new()?; + let log_file = NamedTempFile::new()?; + let data_path = data_file.path().to_path_buf(); + let log_path = log_file.path().to_path_buf(); + + let id = { + let data_links = NamedTypesDecorator::new(&data_path, false)?; + let log_links = NamedTypesDecorator::new(&log_path, false)?; + let mut tx = TransactionsDecorator::new( + data_links, + log_links, + LogRetentionPolicy::default(), + CommitMode::default(), + false, + )?; + tx.begin_transaction()?; + let id = tx.create_and_update(0, 0)?; + tx.commit()?; + tx.save()?; + id + }; + + let data_links = NamedTypesDecorator::new(&data_path, false)?; + let log_links = NamedTypesDecorator::new(&log_path, false)?; + let tx = TransactionsDecorator::new( + data_links, + log_links, + LogRetentionPolicy::default(), + CommitMode::default(), + false, + )?; + + assert!(tx.exists(id), "committed link must survive reopen"); + assert!(tx.last_logged_sequence() >= 2); + assert!(tx.applied_sequence() >= tx.last_logged_sequence()); + Ok(()) +} + +#[test] +fn make_transitions_database_filename_returns_sibling_path() { + let path = TransactionsDecorator::make_transitions_database_filename("/var/data/db.links"); + assert_eq!(path, PathBuf::from("/var/data/db.transitions.links")); + + let path = TransactionsDecorator::make_transitions_database_filename("db.links"); + assert_eq!(path, PathBuf::from("db.transitions.links")); +} + +#[test] +fn nested_transactions_are_rejected() -> Result<()> { + let (mut tx, _guards) = make_tx(); + tx.begin_transaction()?; + let result = tx.begin_transaction(); + assert!(result.is_err(), "nested transactions must be rejected"); + tx.rollback()?; + Ok(()) +} From f65ef8114afb494d6977745b742e9bfd6cb6011a Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 14:00:18 +0000 Subject: [PATCH 09/17] feat(rust): add VersionControlDecorator for time travel and branching Mirrors the C# `VersionControlDecorator`: sits above the `TransactionsDecorator` and adds time travel (`checkout`), branching (`branch`, `switch_branch`), and tagging (`tag`) over the transitions log. Persists branch/tag/applied-pointer markers in a sidecar `.versioncontrol.links` store and rebuilds state on reopen. Adds 11 integration tests mirroring the C# `VersionControlDecoratorTests` and 3 module-level unit tests covering marker round-tripping and the sidecar filename convention. --- rust/src/lib.rs | 2 + rust/src/version_control/mod.rs | 581 ++++++++++++++++++ rust/tests/version_control_decorator_tests.rs | 225 +++++++ 3 files changed, 808 insertions(+) create mode 100644 rust/src/version_control/mod.rs create mode 100644 rust/tests/version_control_decorator_tests.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 36c01fe..18b2a75 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -35,6 +35,7 @@ mod query_types; pub mod sequences; pub mod transactions; mod unicode_string_storage; +pub mod version_control; // Re-export main types for easy access pub use changes_simplifier::simplify_changes; @@ -56,3 +57,4 @@ pub use transactions::{ Transition, TransitionKind, }; pub use unicode_string_storage::UnicodeStringStorage; +pub use version_control::{BranchInfo, VersionControlDecorator, DEFAULT_BRANCH_NAME}; diff --git a/rust/src/version_control/mod.rs b/rust/src/version_control/mod.rs new file mode 100644 index 0000000..cf5eae9 --- /dev/null +++ b/rust/src/version_control/mod.rs @@ -0,0 +1,581 @@ +//! Optional version-control layer for the Rust link-cli. +//! +//! Mirrors the C# `VersionControlDecorator` in +//! `csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs`. +//! +//! Sits above the [`TransactionsDecorator`](crate::transactions::TransactionsDecorator) +//! and adds *time travel* ([`checkout`](VersionControlDecorator::checkout)), +//! *branching* ([`branch`](VersionControlDecorator::branch), +//! [`switch_branch`](VersionControlDecorator::switch_branch)), and +//! *tagging* ([`tag`](VersionControlDecorator::tag)) over the transitions +//! log. Optional — when not instantiated the underlying transactions +//! decorator behaves identically (R17). + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Result}; + +use crate::link::Link; +use crate::named_types::{NamedTypes, NamedTypesDecorator}; +use crate::transactions::{TransactionsDecorator, Transition}; + +/// Default name of the initial branch (analogous to git's `main`). +pub const DEFAULT_BRANCH_NAME: &str = "main"; + +const BRANCH_PREFIX: &str = "__vc:branch:"; +const TAG_PREFIX: &str = "__vc:tag:"; +const CURRENT_PREFIX: &str = "__vc:current="; +const APPLIED_PREFIX: &str = "__vc:applied="; +const TRANSITION_PREFIX: &str = "__vc:trans:"; + +/// Metadata describing one branch in the version-control DAG. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchInfo { + pub name: String, + pub parent: Option, + pub fork_seq: i64, + pub head: i64, +} + +impl BranchInfo { + pub fn new(name: String, parent: Option, fork_seq: i64, head: i64) -> Self { + Self { + name, + parent, + fork_seq, + head, + } + } +} + +/// Decorator that adds *time travel*, *branching*, and *tagging* over the +/// transitions log produced by a [`TransactionsDecorator`]. +pub struct VersionControlDecorator { + transactions: TransactionsDecorator, + branches_store: NamedTypesDecorator, + branches: HashMap, + tags: BTreeMap, + transition_branches: BTreeMap, + branch_links: HashMap, + tag_links: HashMap, + current_branch_link: u32, + applied_link: u32, + current_branch: String, + current_applied: i64, + trace: bool, +} + +impl VersionControlDecorator { + pub fn new( + transactions: TransactionsDecorator, + branches_store: NamedTypesDecorator, + trace: bool, + ) -> Result { + let mut decorator = Self { + transactions, + branches_store, + branches: HashMap::new(), + tags: BTreeMap::new(), + transition_branches: BTreeMap::new(), + branch_links: HashMap::new(), + tag_links: HashMap::new(), + current_branch_link: 0, + applied_link: 0, + current_branch: DEFAULT_BRANCH_NAME.to_string(), + current_applied: 0, + trace, + }; + decorator.recover()?; + decorator.ensure_default_branch()?; + Ok(decorator) + } + + /// Conventional sidecar filename for the version-control store. + pub fn make_version_control_database_filename>(p: P) -> PathBuf { + let path = p.as_ref(); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let name = format!("{stem}.versioncontrol.links"); + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(name), + _ => PathBuf::from(name), + } + } + + pub fn current_branch(&self) -> &str { + &self.current_branch + } + + pub fn current_sequence(&self) -> i64 { + self.current_applied + } + + pub fn list_branches(&self) -> Vec { + let mut branches: Vec = self.branches.values().cloned().collect(); + branches.sort_by(|a, b| a.name.cmp(&b.name)); + branches + } + + pub fn list_tags(&self) -> BTreeMap { + self.tags.clone() + } + + pub fn try_get_tag(&self, name: &str) -> Option { + self.tags.get(name).copied() + } + + pub fn save(&self) -> Result<()> { + self.transactions.save()?; + self.branches_store.save()?; + Ok(()) + } + + pub fn transactions(&self) -> &TransactionsDecorator { + &self.transactions + } + + pub fn transactions_mut(&mut self) -> &mut TransactionsDecorator { + &mut self.transactions + } + + pub fn branches_store(&self) -> &NamedTypesDecorator { + &self.branches_store + } + + // -- Write API (attribute new transitions to the current branch) ---- + + pub fn create(&mut self, source: u32, target: u32) -> Result { + let before_seq = self.transactions.last_logged_sequence(); + let id = self.transactions.create(source, target)?; + self.attribute_new_transitions(before_seq)?; + Ok(id) + } + + pub fn update(&mut self, id: u32, source: u32, target: u32) -> Result { + let before_seq = self.transactions.last_logged_sequence(); + let result = self.transactions.update(id, source, target)?; + self.attribute_new_transitions(before_seq)?; + Ok(result) + } + + pub fn delete(&mut self, id: u32) -> Result { + let before_seq = self.transactions.last_logged_sequence(); + let result = self.transactions.delete(id)?; + self.attribute_new_transitions(before_seq)?; + Ok(result) + } + + pub fn create_and_update(&mut self, source: u32, target: u32) -> Result { + let before_seq = self.transactions.last_logged_sequence(); + let id = self.transactions.create_and_update(source, target)?; + self.attribute_new_transitions(before_seq)?; + Ok(id) + } + + pub fn exists(&self, id: u32) -> bool { + self.transactions.exists(id) + } + + pub fn get(&self, id: u32) -> Option<&Link> { + self.transactions.get(id) + } + + pub fn all(&self) -> Vec<&Link> { + self.transactions.all() + } + + fn attribute_new_transitions(&mut self, before_seq: i64) -> Result<()> { + let after_seq = self.transactions.last_logged_sequence(); + if after_seq <= before_seq { + return Ok(()); + } + let branch_name = self.current_branch.clone(); + for s in (before_seq + 1)..=after_seq { + self.transition_branches.insert(s, branch_name.clone()); + let marker = format!("{TRANSITION_PREFIX}{s}:branch={branch_name}"); + self.write_immutable_marker(&marker)?; + } + if let Some(info) = self.branches.get(&branch_name).cloned() { + let updated = BranchInfo { + head: after_seq, + ..info + }; + self.branches.insert(branch_name.clone(), updated.clone()); + self.update_branch_link(&updated)?; + } + self.current_applied = after_seq; + self.set_applied(after_seq)?; + Ok(()) + } + + // -- Branching ---------------------------------------------------- + + pub fn branch(&mut self, name: &str, from: Option) -> Result<()> { + if name.trim().is_empty() { + bail!("Branch name must not be empty."); + } + if self.branches.contains_key(name) { + bail!("Branch '{name}' already exists."); + } + let parent = self.current_branch.clone(); + let fork_seq = from.unwrap_or(self.current_applied); + if fork_seq < 0 { + bail!("Fork point cannot be negative."); + } + if fork_seq > 0 { + let path = self.build_branch_seqs(&parent); + if !path.contains(&fork_seq) { + bail!( + "Fork point {fork_seq} is not reachable on branch '{parent}'.", + ); + } + } + self.create_branch(name, Some(parent), fork_seq, fork_seq)?; + self.trace(&format!( + "Created branch '{name}' from '{}' at seq {fork_seq}.", + self.current_branch + )); + Ok(()) + } + + pub fn switch_branch(&mut self, name: &str) -> Result<()> { + if !self.branches.contains_key(name) { + bail!("Unknown branch '{name}'."); + } + let target_path = self.build_branch_seqs(name); + self.apply_diff_to(target_path, name)?; + self.trace(&format!( + "Switched to branch '{name}' at seq {}.", + self.current_applied + )); + Ok(()) + } + + pub fn checkout(&mut self, sequence: i64) -> Result<()> { + if sequence < 0 { + bail!("Sequence must be non-negative."); + } + let current = self.current_branch.clone(); + let path = self.build_branch_seqs(¤t); + if sequence > 0 && !path.contains(&sequence) { + bail!( + "Sequence {sequence} is not reachable on branch '{current}'.", + ); + } + let target_path: Vec = path.iter().copied().filter(|s| *s <= sequence).collect(); + self.apply_diff_to(target_path, ¤t)?; + self.trace(&format!( + "Checked out seq {sequence} on branch '{current}'.", + )); + Ok(()) + } + + pub fn tag(&mut self, name: &str, sequence: Option) -> Result<()> { + if name.trim().is_empty() { + bail!("Tag name must not be empty."); + } + let seq = sequence.unwrap_or(self.current_applied); + if seq < 0 { + bail!("Tag sequence must be non-negative."); + } + self.tags.insert(name.to_string(), seq); + self.update_tag_link(name, seq)?; + self.trace(&format!("Created tag '{name}' at seq {seq}.",)); + Ok(()) + } + + // -- Path / diff helpers ----------------------------------------- + + fn apply_diff_to(&mut self, target_path: Vec, new_branch: &str) -> Result<()> { + let current_branch_name = self.current_branch.clone(); + let current_path: Vec = self + .build_branch_seqs(¤t_branch_name) + .into_iter() + .filter(|s| *s <= self.current_applied) + .collect(); + + let mut common = 0usize; + let max_common = current_path.len().min(target_path.len()); + while common < max_common && current_path[common] == target_path[common] { + common += 1; + } + + // Revert transitions that are no longer on the path. + let to_revert: Vec = current_path[common..].iter().rev().copied().collect(); + for seq in to_revert { + if let Some(transition) = self.find_transition(seq) { + self.transactions.revert_transition(&transition); + } + } + // Apply transitions that are new on the path. + let to_apply: Vec = target_path[common..].to_vec(); + for seq in to_apply { + if let Some(transition) = self.find_transition(seq) { + self.transactions.apply_transition(&transition); + } + } + + if new_branch != self.current_branch { + self.current_branch = new_branch.to_string(); + self.set_current_branch(new_branch)?; + } + self.current_applied = target_path.last().copied().unwrap_or(0); + self.set_applied(self.current_applied)?; + Ok(()) + } + + fn build_branch_seqs(&self, branch_name: &str) -> Vec { + let mut visited: HashSet = HashSet::new(); + self.build_branch_seqs_inner(branch_name, &mut visited) + } + + fn build_branch_seqs_inner( + &self, + branch_name: &str, + visited: &mut HashSet, + ) -> Vec { + let info = match self.branches.get(branch_name) { + Some(info) => info, + None => return Vec::new(), + }; + if !visited.insert(branch_name.to_string()) { + return Vec::new(); + } + let mut seqs = Vec::new(); + if let Some(parent_name) = info.parent.as_deref() { + if self.branches.contains_key(parent_name) { + let mut parent_seqs = self.build_branch_seqs_inner(parent_name, visited); + parent_seqs.retain(|s| *s <= info.fork_seq); + seqs.extend(parent_seqs); + } + } + let mut own: Vec = self + .transition_branches + .iter() + .filter(|(s, b)| b.as_str() == branch_name && **s <= info.head) + .map(|(s, _)| *s) + .collect(); + own.sort(); + seqs.extend(own); + seqs + } + + fn find_transition(&self, sequence: i64) -> Option { + self.transactions + .log() + .into_iter() + .find(|t| t.sequence == sequence) + } + + // -- Persistence helpers ----------------------------------------- + + fn ensure_default_branch(&mut self) -> Result<()> { + let existing = self.transactions.last_logged_sequence(); + if !self.branches.contains_key(DEFAULT_BRANCH_NAME) { + // Pre-existing transitions are attributed to the default branch. + for s in 1..=existing { + if !self.transition_branches.contains_key(&s) { + self.transition_branches + .insert(s, DEFAULT_BRANCH_NAME.to_string()); + let marker = + format!("{TRANSITION_PREFIX}{s}:branch={DEFAULT_BRANCH_NAME}"); + self.write_immutable_marker(&marker)?; + } + } + self.create_branch(DEFAULT_BRANCH_NAME, None, 0, existing)?; + self.current_branch = DEFAULT_BRANCH_NAME.to_string(); + self.current_applied = existing; + self.set_current_branch(DEFAULT_BRANCH_NAME)?; + self.set_applied(existing)?; + } else if self.current_branch_link == 0 { + let branch = self.current_branch.clone(); + self.set_current_branch(&branch)?; + } + Ok(()) + } + + fn create_branch( + &mut self, + name: &str, + parent: Option, + fork_seq: i64, + head: i64, + ) -> Result<()> { + let info = BranchInfo::new(name.to_string(), parent, fork_seq, head); + self.branches.insert(name.to_string(), info.clone()); + self.update_branch_link(&info)?; + Ok(()) + } + + fn update_branch_link(&mut self, info: &BranchInfo) -> Result<()> { + let marker = encode_branch_marker(info); + let link = match self.branch_links.get(&info.name).copied() { + Some(link) => link, + None => { + let new_link = self.branches_store.create(0, 0); + self.branch_links.insert(info.name.clone(), new_link); + new_link + } + }; + self.branches_store.set_name(link, &marker)?; + Ok(()) + } + + fn update_tag_link(&mut self, name: &str, seq: i64) -> Result<()> { + let marker = format!("{TAG_PREFIX}{name}={seq}"); + let link = match self.tag_links.get(name).copied() { + Some(link) => link, + None => { + let new_link = self.branches_store.create(0, 0); + self.tag_links.insert(name.to_string(), new_link); + new_link + } + }; + self.branches_store.set_name(link, &marker)?; + Ok(()) + } + + fn set_current_branch(&mut self, name: &str) -> Result<()> { + self.current_branch = name.to_string(); + if self.current_branch_link == 0 { + self.current_branch_link = self.branches_store.create(0, 0); + } + let link = self.current_branch_link; + let marker = format!("{CURRENT_PREFIX}{name}"); + self.branches_store.set_name(link, &marker)?; + Ok(()) + } + + fn set_applied(&mut self, seq: i64) -> Result<()> { + if self.applied_link == 0 { + self.applied_link = self.branches_store.create(0, 0); + } + let link = self.applied_link; + let marker = format!("{APPLIED_PREFIX}{seq}"); + self.branches_store.set_name(link, &marker)?; + Ok(()) + } + + fn write_immutable_marker(&mut self, name: &str) -> Result<()> { + let link = self.branches_store.create(0, 0); + self.branches_store.set_name(link, name)?; + Ok(()) + } + + pub fn recover(&mut self) -> Result<()> { + self.branches.clear(); + self.tags.clear(); + self.transition_branches.clear(); + self.branch_links.clear(); + self.tag_links.clear(); + self.current_branch = DEFAULT_BRANCH_NAME.to_string(); + self.current_branch_link = 0; + self.applied_link = 0; + self.current_applied = 0; + + let links: Vec = self.branches_store.all().into_iter().copied().collect(); + for link in &links { + let name = match self.branches_store.get_name(link.index)? { + Some(value) => value, + None => continue, + }; + if name.starts_with(BRANCH_PREFIX) { + if let Some(info) = try_decode_branch_marker(&name) { + self.branches.insert(info.name.clone(), info.clone()); + self.branch_links.insert(info.name.clone(), link.index); + } + } else if let Some(rest) = name.strip_prefix(CURRENT_PREFIX) { + self.current_branch = rest.to_string(); + self.current_branch_link = link.index; + } else if let Some(rest) = name.strip_prefix(APPLIED_PREFIX) { + if let Ok(seq) = rest.parse::() { + self.current_applied = seq; + self.applied_link = link.index; + } + } else if let Some(rest) = name.strip_prefix(TAG_PREFIX) { + if let Some(eq) = rest.find('=') { + let tag_name = &rest[..eq]; + if let Ok(tag_seq) = rest[eq + 1..].parse::() { + self.tags.insert(tag_name.to_string(), tag_seq); + self.tag_links.insert(tag_name.to_string(), link.index); + } + } + } else if let Some(rest) = name.strip_prefix(TRANSITION_PREFIX) { + if let Some(colon) = rest.find(":branch=") { + if let Ok(seq) = rest[..colon].parse::() { + let branch_name = &rest[colon + ":branch=".len()..]; + self.transition_branches.insert(seq, branch_name.to_string()); + } + } + } + } + Ok(()) + } + + fn trace(&self, message: &str) { + if self.trace { + eprintln!("[VersionControl] {message}"); + } + } +} + +fn encode_branch_marker(info: &BranchInfo) -> String { + let parent = info.parent.as_deref().unwrap_or(""); + format!( + "{BRANCH_PREFIX}{name}:parent={parent}:fork={fork}:head={head}", + name = info.name, + fork = info.fork_seq, + head = info.head, + ) +} + +fn try_decode_branch_marker(text: &str) -> Option { + let rest = text.strip_prefix(BRANCH_PREFIX)?; + let parent_idx = rest.find(":parent=")?; + let name = &rest[..parent_idx]; + let rest = &rest[parent_idx + ":parent=".len()..]; + let fork_idx = rest.find(":fork=")?; + let parent_text = &rest[..fork_idx]; + let rest = &rest[fork_idx + ":fork=".len()..]; + let head_idx = rest.find(":head=")?; + let fork_text = &rest[..head_idx]; + let head_text = &rest[head_idx + ":head=".len()..]; + let fork: i64 = fork_text.parse().ok()?; + let head: i64 = head_text.parse().ok()?; + let parent = if parent_text.is_empty() { + None + } else { + Some(parent_text.to_string()) + }; + Some(BranchInfo::new(name.to_string(), parent, fork, head)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_round_trips_through_decode() { + let info = BranchInfo::new("feature".into(), Some("main".into()), 5, 9); + let text = encode_branch_marker(&info); + let decoded = try_decode_branch_marker(&text).unwrap(); + assert_eq!(info, decoded); + } + + #[test] + fn make_version_control_database_filename_returns_sibling_path() { + let path = VersionControlDecorator::make_version_control_database_filename( + "/var/data/db.links", + ); + assert_eq!(path, PathBuf::from("/var/data/db.versioncontrol.links")); + } + + #[test] + fn decode_branch_marker_rejects_invalid_input() { + assert!(try_decode_branch_marker("not a marker").is_none()); + assert!(try_decode_branch_marker("__vc:branch:x:parent=:fork=z:head=1").is_none()); + } +} diff --git a/rust/tests/version_control_decorator_tests.rs b/rust/tests/version_control_decorator_tests.rs new file mode 100644 index 0000000..7a031a3 --- /dev/null +++ b/rust/tests/version_control_decorator_tests.rs @@ -0,0 +1,225 @@ +//! Integration tests for the optional version-control decorator. +//! +//! Mirrors the C# `VersionControlDecoratorTests` and exercises R11–R16 +//! of issue #94. + +use anyhow::Result; +use link_cli::{ + CommitMode, LogRetentionPolicy, NamedTypesDecorator, TransactionsDecorator, + VersionControlDecorator, DEFAULT_BRANCH_NAME, +}; +use tempfile::NamedTempFile; + +struct VcGuard { + _data_file: NamedTempFile, + _log_file: NamedTempFile, + _vc_file: NamedTempFile, +} + +fn make_vc() -> (VersionControlDecorator, VcGuard) { + let data_file = NamedTempFile::new().unwrap(); + let log_file = NamedTempFile::new().unwrap(); + let vc_file = NamedTempFile::new().unwrap(); + let data_links = NamedTypesDecorator::new(data_file.path(), false).unwrap(); + let log_links = NamedTypesDecorator::new(log_file.path(), false).unwrap(); + let vc_links = NamedTypesDecorator::new(vc_file.path(), false).unwrap(); + let tx = TransactionsDecorator::new( + data_links, + log_links, + LogRetentionPolicy::default(), + CommitMode::default(), + false, + ) + .unwrap(); + let vc = VersionControlDecorator::new(tx, vc_links, false).unwrap(); + ( + vc, + VcGuard { + _data_file: data_file, + _log_file: log_file, + _vc_file: vc_file, + }, + ) +} + +#[test] +fn default_branch_exists_on_first_open() { + let (vc, _guard) = make_vc(); + assert_eq!(DEFAULT_BRANCH_NAME, vc.current_branch()); + let branches = vc.list_branches(); + assert_eq!(1, branches.len()); + assert_eq!(DEFAULT_BRANCH_NAME, branches[0].name); +} + +#[test] +fn new_transitions_are_attributed_to_current_branch() -> Result<()> { + let (mut vc, _guard) = make_vc(); + let _id = vc.create_and_update(0, 0)?; + let head = vc.transactions().last_logged_sequence(); + assert!( + head >= 2, + "create_and_update must produce at least two transitions (got {head})." + ); + assert_eq!(head, vc.current_sequence()); + Ok(()) +} + +#[test] +fn checkout_to_zero_rewinds_everything() -> Result<()> { + let (mut vc, _guard) = make_vc(); + let a = vc.create_and_update(0, 0)?; + let b = vc.create_and_update(0, 0)?; + assert!(vc.exists(a)); + assert!(vc.exists(b)); + + vc.checkout(0)?; + + assert!(!vc.exists(a), "all links must be rewound after checkout 0"); + assert!(!vc.exists(b)); + assert_eq!(0, vc.current_sequence()); + Ok(()) +} + +#[test] +fn checkout_and_forward_replay_restores_state() -> Result<()> { + let (mut vc, _guard) = make_vc(); + let a = vc.create_and_update(0, 0)?; + let after_first = vc.transactions().last_logged_sequence(); + let b = vc.create_and_update(0, 0)?; + let after_second = vc.transactions().last_logged_sequence(); + + vc.checkout(after_first)?; + assert!(vc.exists(a), "first link must remain after partial rewind"); + assert!(!vc.exists(b), "second link must disappear after partial rewind"); + + vc.checkout(after_second)?; + assert!(vc.exists(a)); + assert!(vc.exists(b), "second link must reappear after forward checkout"); + Ok(()) +} + +#[test] +fn branch_forks_from_current_head() -> Result<()> { + let (mut vc, _guard) = make_vc(); + vc.create_and_update(0, 0)?; + vc.branch("feature", None)?; + assert!(vc.list_branches().iter().any(|b| b.name == "feature")); + Ok(()) +} + +#[test] +fn switch_branch_applies_and_rewinds_transitions() -> Result<()> { + let (mut vc, _guard) = make_vc(); + let a = vc.create_and_update(0, 0)?; + let head_before_branch = vc.current_sequence(); + + vc.branch("feature", None)?; + vc.switch_branch("feature")?; + assert_eq!("feature", vc.current_branch()); + + let b = vc.create_and_update(0, 0)?; + assert!(vc.exists(b)); + let feature_head = vc.current_sequence(); + + vc.switch_branch(DEFAULT_BRANCH_NAME)?; + assert_eq!(DEFAULT_BRANCH_NAME, vc.current_branch()); + assert!(vc.exists(a), "main-branch link must remain after switching back"); + assert!(!vc.exists(b), "feature-branch link must disappear after switching back to main"); + assert_eq!(head_before_branch, vc.current_sequence()); + + vc.switch_branch("feature")?; + assert!(vc.exists(a)); + assert!(vc.exists(b), "feature-branch link must reappear"); + assert_eq!(feature_head, vc.current_sequence()); + Ok(()) +} + +#[test] +fn tag_points_to_current_head() -> Result<()> { + let (mut vc, _guard) = make_vc(); + vc.create_and_update(0, 0)?; + vc.tag("v1", None)?; + let seq = vc.try_get_tag("v1").expect("tag must be retrievable"); + assert_eq!(vc.current_sequence(), seq); + assert!(vc.list_tags().contains_key("v1")); + Ok(()) +} + +#[test] +fn branch_from_explicit_seq_uses_given_point() -> Result<()> { + let (mut vc, _guard) = make_vc(); + vc.create_and_update(0, 0)?; + let first_head = vc.current_sequence(); + vc.create_and_update(0, 0)?; + + vc.branch("backport", Some(first_head))?; + let branches = vc.list_branches(); + let branch = branches + .iter() + .find(|b| b.name == "backport") + .expect("backport branch must exist"); + assert_eq!(first_head, branch.fork_seq); + Ok(()) +} + +#[test] +fn recover_rebuilds_state_from_branches_store() -> Result<()> { + let data_file = NamedTempFile::new()?; + let log_file = NamedTempFile::new()?; + let vc_file = NamedTempFile::new()?; + let data_path = data_file.path().to_path_buf(); + let log_path = log_file.path().to_path_buf(); + let vc_path = vc_file.path().to_path_buf(); + + { + let data_links = NamedTypesDecorator::new(&data_path, false)?; + let log_links = NamedTypesDecorator::new(&log_path, false)?; + let vc_links = NamedTypesDecorator::new(&vc_path, false)?; + let tx = TransactionsDecorator::new( + data_links, + log_links, + LogRetentionPolicy::default(), + CommitMode::default(), + false, + )?; + let mut vc = VersionControlDecorator::new(tx, vc_links, false)?; + vc.create_and_update(0, 0)?; + vc.tag("checkpoint", None)?; + vc.branch("feature", None)?; + vc.save()?; + } + + let data_links = NamedTypesDecorator::new(&data_path, false)?; + let log_links = NamedTypesDecorator::new(&log_path, false)?; + let vc_links = NamedTypesDecorator::new(&vc_path, false)?; + let tx = TransactionsDecorator::new( + data_links, + log_links, + LogRetentionPolicy::default(), + CommitMode::default(), + false, + )?; + let reopened = VersionControlDecorator::new(tx, vc_links, false)?; + assert!(reopened + .list_branches() + .iter() + .any(|b| b.name == "feature")); + assert!(reopened.try_get_tag("checkpoint").is_some()); + Ok(()) +} + +#[test] +fn checkout_out_of_range_throws() -> Result<()> { + let (mut vc, _guard) = make_vc(); + vc.create_and_update(0, 0)?; + assert!(vc.checkout(999).is_err()); + Ok(()) +} + +#[test] +fn duplicate_branch_throws() -> Result<()> { + let (mut vc, _guard) = make_vc(); + vc.branch("feature", None)?; + assert!(vc.branch("feature", None).is_err()); + Ok(()) +} From 2847255bedc244d2f3695109388deadba663f5d0 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 14:10:04 +0000 Subject: [PATCH 10/17] feat(rust): wire CLI flags through transactions and version-control decorators Adds 13 new CLI flags mirroring the C# command surface (--transactions, --transactions-file, --commit-mode, --retention, --vc, --vc-file, --branch, --branch-from, --checkout, --tag, --list-branches, --list-tags, --log) and three execution paths in main.rs: bare, transactions-wrapped, and version-control-wrapped. Implements NamedTypeLinks for both TransactionsDecorator and VersionControlDecorator so the query processor can operate at any layer of the stack. Adds search / get_or_create / ensure_created accessors needed by the trait. Cleans up clippy lints: boxes the Cli payload in CliCommand::Run, derives Default on LogRetentionPolicy via the new attribute syntax, and uses BTreeMap::Entry in the default-branch attribution loop. --- rust/src/cli.rs | 181 ++++++++++++- rust/src/main.rs | 242 +++++++++++++++++- rust/src/named_type_links.rs | 120 +++++++++ rust/src/transactions/mod.rs | 64 +++-- rust/src/version_control/mod.rs | 41 +-- rust/tests/cli_arguments_tests.rs | 2 +- rust/tests/transactions_decorator_tests.rs | 18 +- rust/tests/version_control_decorator_tests.rs | 25 +- 8 files changed, 619 insertions(+), 74 deletions(-) diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 9c60d5b..fa27abe 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -19,6 +19,19 @@ pub struct Cli { pub after: bool, pub lino_input: Option, pub lino_output: Option, + pub transactions: bool, + pub transactions_file: Option, + pub commit_mode: Option, + pub retention: Option, + pub vc: bool, + pub vc_file: Option, + pub branch: Option, + pub branch_from: Option, + pub checkout: Option, + pub tag: Option, + pub list_branches: bool, + pub list_tags: bool, + pub show_log: bool, } impl Default for Cli { @@ -35,18 +48,53 @@ impl Default for Cli { after: false, lino_input: None, lino_output: None, + transactions: false, + transactions_file: None, + commit_mode: None, + retention: None, + vc: false, + vc_file: None, + branch: None, + branch_from: None, + checkout: None, + tag: None, + list_branches: false, + list_tags: false, + show_log: false, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum CliCommand { - Run(Cli), + Run(Box), Help, Version, } impl Cli { + /// True when any flag in the transactions decorator family was passed. + pub fn transactions_requested(&self) -> bool { + self.transactions + || self.transactions_file.is_some() + || self.commit_mode.is_some() + || self.retention.is_some() + || self.show_log + || self.vc_requested() + } + + /// True when any flag in the version-control decorator family was passed. + pub fn vc_requested(&self) -> bool { + self.vc + || self.vc_file.is_some() + || self.branch.is_some() + || self.branch_from.is_some() + || self.checkout.is_some() + || self.tag.is_some() + || self.list_branches + || self.list_tags + } + pub fn parse() -> Result { lino_arguments::init(); Self::parse_from(env::args_os()) @@ -107,6 +155,58 @@ impl Cli { cli.lino_input = Some(value.to_string()); continue; } + if let Some(value) = inline_value(&arg, &["--transactions"]) { + cli.transactions = parse_bool("--transactions", value)?; + continue; + } + if let Some(value) = inline_value(&arg, &["--transactions-file"]) { + cli.transactions_file = Some(value.to_string()); + continue; + } + if let Some(value) = inline_value(&arg, &["--commit-mode"]) { + cli.commit_mode = Some(value.to_string()); + continue; + } + if let Some(value) = inline_value(&arg, &["--retention"]) { + cli.retention = Some(value.to_string()); + continue; + } + if let Some(value) = inline_value(&arg, &["--vc"]) { + cli.vc = parse_bool("--vc", value)?; + continue; + } + if let Some(value) = inline_value(&arg, &["--vc-file"]) { + cli.vc_file = Some(value.to_string()); + continue; + } + if let Some(value) = inline_value(&arg, &["--branch"]) { + cli.branch = Some(value.to_string()); + continue; + } + if let Some(value) = inline_value(&arg, &["--branch-from"]) { + cli.branch_from = Some(parse_seq("--branch-from", value)?); + continue; + } + if let Some(value) = inline_value(&arg, &["--checkout"]) { + cli.checkout = Some(value.to_string()); + continue; + } + if let Some(value) = inline_value(&arg, &["--tag"]) { + cli.tag = Some(value.to_string()); + continue; + } + if let Some(value) = inline_value(&arg, &["--list-branches"]) { + cli.list_branches = parse_bool("--list-branches", value)?; + continue; + } + if let Some(value) = inline_value(&arg, &["--list-tags"]) { + cli.list_tags = parse_bool("--list-tags", value)?; + continue; + } + if let Some(value) = inline_value(&arg, &["--log"]) { + cli.show_log = parse_bool("--log", value)?; + continue; + } match arg.as_str() { "-h" | "--help" => return Ok(CliCommand::Help), @@ -142,6 +242,46 @@ impl Cli { "--in" | "--lino-input" | "--import" => { cli.lino_input = Some(next_value(&mut args, &arg)?); } + "--transactions" => { + cli.transactions = next_bool_value(&mut args, true)?; + } + "--transactions-file" => { + cli.transactions_file = Some(next_value(&mut args, &arg)?); + } + "--commit-mode" => { + cli.commit_mode = Some(next_value(&mut args, &arg)?); + } + "--retention" => { + cli.retention = Some(next_value(&mut args, &arg)?); + } + "--vc" => { + cli.vc = next_bool_value(&mut args, true)?; + } + "--vc-file" => { + cli.vc_file = Some(next_value(&mut args, &arg)?); + } + "--branch" => { + cli.branch = Some(next_value(&mut args, &arg)?); + } + "--branch-from" => { + let value = next_value(&mut args, &arg)?; + cli.branch_from = Some(parse_seq(&arg, &value)?); + } + "--checkout" => { + cli.checkout = Some(next_value(&mut args, &arg)?); + } + "--tag" => { + cli.tag = Some(next_value(&mut args, &arg)?); + } + "--list-branches" => { + cli.list_branches = next_bool_value(&mut args, true)?; + } + "--list-tags" => { + cli.list_tags = next_bool_value(&mut args, true)?; + } + "--log" => { + cli.show_log = next_bool_value(&mut args, true)?; + } "--" => { for value in args.by_ref() { set_positional_query(&mut cli, value)?; @@ -157,7 +297,7 @@ impl Cli { } } - Ok(CliCommand::Run(cli)) + Ok(CliCommand::Run(Box::new(cli))) } pub fn print_help() { @@ -191,6 +331,37 @@ impl Cli { " Read and import a LiNo file into the database\n", " --out , --lino-output , --export \n", " Write the complete database as a LiNo file\n", + " --transactions\n", + " Enable the transactions layer (default log path: .transitions.links)\n", + " --transactions-file \n", + " Path to the transitions log store (implies --transactions)\n", + " --commit-mode \n", + " Choose 'sync' or 'async' commits (default: sync, implies --transactions)\n", + " --retention \n", + " Log retention policy: 'infinite', 'sized:', or 'chunked::'\n", + " (implies --transactions)\n", + " --vc\n", + " Enable the version-control decorator (implies --transactions)\n", + " --vc-file \n", + " Path to the version-control branches store\n", + " (default: .versioncontrol.links)\n", + " --branch \n", + " Switch to a branch (creating it if --branch-from is also passed).\n", + " Implies --vc.\n", + " --branch-from \n", + " When creating a branch with --branch, fork from this sequence point\n", + " --checkout \n", + " Time-travel to a specific transition sequence or named tag.\n", + " Implies --vc.\n", + " --tag \n", + " Create a tag at current head or at the given sequence point.\n", + " Implies --vc.\n", + " --list-branches\n", + " List version-control branches and exit\n", + " --list-tags\n", + " List version-control tags and exit\n", + " --log\n", + " Print the transitions log and exit (implies --transactions)\n", " -h, --help\n", " Print help\n", " -V, --version\n", @@ -249,6 +420,12 @@ fn parse_link_id(option: &str, value: &str) -> Result { .map_err(|_| anyhow::anyhow!("invalid link id '{value}' for {option}")) } +fn parse_seq(option: &str, value: &str) -> Result { + value + .parse() + .map_err(|_| anyhow::anyhow!("invalid sequence value '{value}' for {option}")) +} + fn set_positional_query(cli: &mut Cli, value: String) -> Result<()> { if cli.query_arg.is_some() { bail!("unexpected extra positional argument '{value}'"); diff --git a/rust/src/main.rs b/rust/src/main.rs index cdea764..c99e8a6 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -3,14 +3,17 @@ //! This is the Rust implementation of the link-cli tool, providing //! similar functionality to the C# version. -use anyhow::Result; +use anyhow::{anyhow, bail, Result}; use link_cli::cli::{Cli, CliCommand}; use link_cli::import_lino_file; -use link_cli::{NamedTypeLinks, NamedTypesDecorator, QueryProcessor}; +use link_cli::{ + CommitMode, LogRetentionPolicy, NamedTypeLinks, NamedTypesDecorator, QueryProcessor, + TransactionsDecorator, VersionControlDecorator, +}; fn main() -> Result<()> { let cli = match Cli::parse()? { - CliCommand::Run(cli) => cli, + CliCommand::Run(cli) => *cli, CliCommand::Help => { Cli::print_help(); return Ok(()); @@ -21,51 +24,262 @@ fn main() -> Result<()> { } }; - // Create link storage with separate named-type aliases. + let vc_requested = cli.vc_requested(); + let transactions_requested = cli.transactions_requested(); + + let commit_mode = parse_commit_mode(cli.commit_mode.as_deref())?; + let retention_policy = parse_retention(cli.retention.as_deref())?; + + if vc_requested { + run_with_vc(&cli, commit_mode, retention_policy) + } else if transactions_requested { + run_with_transactions(&cli, commit_mode, retention_policy) + } else { + run_bare(&cli) + } +} + +fn parse_commit_mode(raw: Option<&str>) -> Result { + match raw.map(|s| s.trim()).filter(|s| !s.is_empty()) { + None => Ok(CommitMode::Sync), + Some(value) if value.eq_ignore_ascii_case("sync") => Ok(CommitMode::Sync), + Some(value) if value.eq_ignore_ascii_case("async") => Ok(CommitMode::Async), + Some(other) => bail!("Invalid --commit-mode value '{other}'. Use 'sync' or 'async'."), + } +} + +fn parse_retention(raw: Option<&str>) -> Result { + match raw.map(|s| s.trim()).filter(|s| !s.is_empty()) { + None => Ok(LogRetentionPolicy::Infinite), + Some(value) => LogRetentionPolicy::parse(value) + .map_err(|e| anyhow!("Invalid --retention value '{value}': {e}")), + } +} + +fn run_bare(cli: &Cli) -> Result<()> { let mut storage = NamedTypesDecorator::new(&cli.db, cli.trace)?; + run_query_pipeline(cli, &mut storage)?; + storage.save()?; + Ok(()) +} + +fn run_with_transactions( + cli: &Cli, + commit_mode: CommitMode, + retention_policy: LogRetentionPolicy, +) -> Result<()> { + let data_links = NamedTypesDecorator::new(&cli.db, cli.trace)?; + let log_path = cli + .transactions_file + .clone() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| TransactionsDecorator::make_transitions_database_filename(&cli.db)); + let log_links = NamedTypesDecorator::new(&log_path, cli.trace)?; + let mut tx = TransactionsDecorator::new( + data_links, + log_links, + retention_policy, + commit_mode, + cli.trace, + )?; + + if cli.show_log { + for transition in tx.log() { + println!( + "{}\t{}\t{:?}\t{:032x}\t({},{},{}) -> ({},{},{})", + transition.sequence, + transition.timestamp_ms, + transition.kind, + transition.transaction_id, + transition.before.index, + transition.before.source, + transition.before.target, + transition.after.index, + transition.after.source, + transition.after.target, + ); + } + tx.save()?; + return Ok(()); + } + + run_query_pipeline(cli, &mut tx)?; + tx.save()?; + Ok(()) +} + +fn run_with_vc( + cli: &Cli, + commit_mode: CommitMode, + retention_policy: LogRetentionPolicy, +) -> Result<()> { + let data_links = NamedTypesDecorator::new(&cli.db, cli.trace)?; + let log_path = cli + .transactions_file + .clone() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| TransactionsDecorator::make_transitions_database_filename(&cli.db)); + let log_links = NamedTypesDecorator::new(&log_path, cli.trace)?; + let tx = TransactionsDecorator::new( + data_links, + log_links, + retention_policy, + commit_mode, + cli.trace, + )?; + let vc_path = cli + .vc_file + .clone() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + VersionControlDecorator::make_version_control_database_filename(&cli.db) + }); + let vc_links = NamedTypesDecorator::new(&vc_path, cli.trace)?; + let mut vc = VersionControlDecorator::new(tx, vc_links, cli.trace)?; + + // 1) --checkout (resolves seq or tag). + if let Some(checkout_point) = cli.checkout.as_deref() { + let seq = resolve_sequence(&vc, checkout_point) + .ok_or_else(|| anyhow!("Unknown checkout point '{checkout_point}'."))?; + vc.checkout(seq)?; + if cli.trace { + println!("Checked out seq {seq} on branch '{}'.", vc.current_branch()); + } + } + + // 2) --branch [--branch-from] (creates if missing, then switches). + if let Some(branch_name) = cli.branch.as_deref() { + let exists = vc.list_branches().iter().any(|b| b.name == branch_name); + if !exists { + vc.branch(branch_name, cli.branch_from)?; + if cli.trace { + println!("Created branch '{branch_name}'."); + } + } + vc.switch_branch(branch_name)?; + if cli.trace { + println!("Switched to branch '{branch_name}'."); + } + } + + // 3) --tag. + if let Some(tag_spec) = cli.tag.as_deref() { + let (name, seq) = match tag_spec.find('=') { + None => (tag_spec.to_string(), None), + Some(eq) => { + let (name_part, value_part) = tag_spec.split_at(eq); + let value_part = &value_part[1..]; + let resolved = resolve_sequence(&vc, value_part) + .ok_or_else(|| anyhow!("Unknown tag point '{value_part}'."))?; + (name_part.to_string(), Some(resolved)) + } + }; + vc.tag(&name, seq)?; + if cli.trace { + let resolved = seq.unwrap_or_else(|| vc.current_sequence()); + println!("Tagged '{name}' at seq {resolved}."); + } + } + + // 4) --list-branches / --list-tags (terminal). + if cli.list_branches { + let current = vc.current_branch().to_string(); + for info in vc.list_branches() { + let marker = if info.name == current { "*" } else { " " }; + let parent = info.parent.clone().unwrap_or_else(|| "-".to_string()); + println!( + "{} {}\tparent={}\tfork={}\thead={}", + marker, info.name, parent, info.fork_seq, info.head + ); + } + vc.save()?; + return Ok(()); + } + + if cli.list_tags { + for (name, seq) in vc.list_tags() { + println!("{name}\t{seq}"); + } + vc.save()?; + return Ok(()); + } + + // 5) --log. + if cli.show_log { + for transition in vc.transactions().log() { + println!( + "{}\t{}\t{:?}\t{:032x}\t({},{},{}) -> ({},{},{})", + transition.sequence, + transition.timestamp_ms, + transition.kind, + transition.transaction_id, + transition.before.index, + transition.before.source, + transition.before.target, + transition.after.index, + transition.after.source, + transition.after.target, + ); + } + vc.save()?; + return Ok(()); + } + + run_query_pipeline(cli, &mut vc)?; + vc.save()?; + Ok(()) +} + +fn resolve_sequence(vc: &VersionControlDecorator, point: &str) -> Option { + let trimmed = point.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(direct) = trimmed.parse::() { + return Some(direct); + } + vc.try_get_tag(trimmed) +} - // Print before state if requested +fn run_query_pipeline(cli: &Cli, storage: &mut S) -> Result<()> +where + S: NamedTypeLinks, +{ if cli.before { storage.print_all_lino()?; } if let Some(input_path) = &cli.lino_input { - import_lino_file(&mut storage, input_path)?; + import_lino_file(storage, input_path)?; } - // If --structure is provided, handle it separately if let Some(link_id) = cli.structure { let structure_formatted = storage.format_structure(link_id)?; - println!("{}", structure_formatted); + println!("{structure_formatted}"); if let Some(output_path) = &cli.lino_output { storage.write_lino_output(output_path)?; } return Ok(()); } - // Get effective query (option takes precedence over positional argument) let effective_query = cli.query.as_deref().or(cli.query_arg.as_deref()); - // Collect changes let mut changes_list = Vec::new(); - // Process query if provided if let Some(query) = effective_query { if !query.is_empty() { let processor = QueryProcessor::new(cli.trace) .with_auto_create_missing_references(cli.auto_create_missing_references); - changes_list = processor.process_query(&mut storage, query)?; + changes_list = processor.process_query(storage, query)?; } } - // Print changes if requested if cli.changes && !changes_list.is_empty() { for (before_link, after_link) in &changes_list { storage.print_change_lino(before_link, after_link)?; } } - // Print after state if requested if cli.after { storage.print_all_lino()?; } diff --git a/rust/src/named_type_links.rs b/rust/src/named_type_links.rs index ca61ffa..0945c91 100644 --- a/rust/src/named_type_links.rs +++ b/rust/src/named_type_links.rs @@ -259,6 +259,126 @@ impl NamedTypeLinks for NamedTypesDecorator { } } +impl NamedTypeLinks for crate::transactions::TransactionsDecorator { + fn create(&mut self, source: u32, target: u32) -> u32 { + crate::transactions::TransactionsDecorator::create(self, source, target) + .expect("TransactionsDecorator::create failed in NamedTypeLinks bridge") + } + + fn ensure_created(&mut self, id: u32) -> u32 { + crate::transactions::TransactionsDecorator::ensure_created(self, id) + } + + fn get_link(&mut self, id: u32) -> Option { + self.get(id).copied() + } + + fn exists(&mut self, id: u32) -> bool { + crate::transactions::TransactionsDecorator::exists(self, id) + } + + fn update(&mut self, id: u32, source: u32, target: u32) -> Result { + crate::transactions::TransactionsDecorator::update(self, id, source, target) + } + + fn delete(&mut self, id: u32) -> Result { + crate::transactions::TransactionsDecorator::delete(self, id) + } + + fn all_links(&mut self) -> Vec { + self.all().into_iter().copied().collect() + } + + fn search(&mut self, source: u32, target: u32) -> Option { + crate::transactions::TransactionsDecorator::search(self, source, target) + } + + fn get_or_create(&mut self, source: u32, target: u32) -> u32 { + crate::transactions::TransactionsDecorator::get_or_create(self, source, target) + .expect("TransactionsDecorator::get_or_create failed in NamedTypeLinks bridge") + } + + fn get_name(&mut self, id: u32) -> Result> { + NamedTypes::get_name(self.inner_mut(), id) + } + + fn set_name(&mut self, id: u32, name: &str) -> Result { + NamedTypes::set_name(self.inner_mut(), id, name) + } + + fn get_by_name(&mut self, name: &str) -> Result> { + NamedTypes::get_by_name(self.inner_mut(), name) + } + + fn remove_name(&mut self, id: u32) -> Result<()> { + NamedTypes::remove_name(self.inner_mut(), id) + } + + fn save(&mut self) -> Result<()> { + crate::transactions::TransactionsDecorator::save(self) + } +} + +impl NamedTypeLinks for crate::version_control::VersionControlDecorator { + fn create(&mut self, source: u32, target: u32) -> u32 { + crate::version_control::VersionControlDecorator::create(self, source, target) + .expect("VersionControlDecorator::create failed in NamedTypeLinks bridge") + } + + fn ensure_created(&mut self, id: u32) -> u32 { + crate::version_control::VersionControlDecorator::ensure_created(self, id) + } + + fn get_link(&mut self, id: u32) -> Option { + self.get(id).copied() + } + + fn exists(&mut self, id: u32) -> bool { + crate::version_control::VersionControlDecorator::exists(self, id) + } + + fn update(&mut self, id: u32, source: u32, target: u32) -> Result { + crate::version_control::VersionControlDecorator::update(self, id, source, target) + } + + fn delete(&mut self, id: u32) -> Result { + crate::version_control::VersionControlDecorator::delete(self, id) + } + + fn all_links(&mut self) -> Vec { + self.all().into_iter().copied().collect() + } + + fn search(&mut self, source: u32, target: u32) -> Option { + crate::version_control::VersionControlDecorator::search(self, source, target) + } + + fn get_or_create(&mut self, source: u32, target: u32) -> u32 { + crate::version_control::VersionControlDecorator::get_or_create(self, source, target) + .expect("VersionControlDecorator::get_or_create failed in NamedTypeLinks bridge") + } + + fn get_name(&mut self, id: u32) -> Result> { + NamedTypes::get_name(self.transactions_mut().inner_mut(), id) + } + + fn set_name(&mut self, id: u32, name: &str) -> Result { + NamedTypes::set_name(self.transactions_mut().inner_mut(), id, name) + } + + fn get_by_name(&mut self, name: &str) -> Result> { + NamedTypes::get_by_name(self.transactions_mut().inner_mut(), name) + } + + fn remove_name(&mut self, id: u32) -> Result<()> { + NamedTypes::remove_name(self.transactions_mut().inner_mut(), id) + } + + fn save(&mut self) -> Result<()> { + crate::version_control::VersionControlDecorator::save(self) + } +} + pub(crate) fn escape_lino_reference(reference: &str) -> String { if reference.is_empty() || reference.trim().is_empty() { return String::new(); diff --git a/rust/src/transactions/mod.rs b/rust/src/transactions/mod.rs index 025afed..9c51c66 100644 --- a/rust/src/transactions/mod.rs +++ b/rust/src/transactions/mod.rs @@ -65,9 +65,10 @@ pub enum CommitMode { } /// Retention policy for the transitions log. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum LogRetentionPolicy { /// Keep every transition forever (default). + #[default] Infinite, /// Drop the oldest applied transitions once the live log exceeds /// `max_transitions`. Never drops un-applied transitions (R7). @@ -81,12 +82,6 @@ pub enum LogRetentionPolicy { }, } -impl Default for LogRetentionPolicy { - fn default() -> Self { - Self::Infinite - } -} - impl LogRetentionPolicy { /// Parses a CLI spec: `infinite`, `sized:`, `chunked::`. pub fn parse(spec: &str) -> Result { @@ -482,6 +477,23 @@ impl TransactionsDecorator { self.inner.query(index, source, target) } + pub fn search(&self, source: u32, target: u32) -> Option { + self.inner.search(source, target) + } + + pub fn get_or_create(&mut self, source: u32, target: u32) -> Result { + if let Some(existing) = self.inner.search(source, target) { + return Ok(existing); + } + self.create(source, target) + } + + pub fn ensure_created(&mut self, id: u32) -> u32 { + // ensure_created is used by recovery/replay only and is not + // itself a logical write; bypass transition recording. + self.inner.ensure_created(id) + } + fn ensure_open_transaction(&mut self) -> bool { if self.current.is_none() { self.current = Some(PendingTransaction { @@ -505,11 +517,9 @@ impl TransactionsDecorator { self.sequence_counter += 1; let sequence = self.sequence_counter; let timestamp_ms = now_unix_ms(); - let transaction_id = self - .current - .as_ref() - .map(|tx| tx.id) - .ok_or_else(|| anyhow!("internal: missing open transaction while recording transition"))?; + let transaction_id = self.current.as_ref().map(|tx| tx.id).ok_or_else(|| { + anyhow!("internal: missing open transaction while recording transition") + })?; let transition = Transition { transaction_id, sequence, @@ -743,10 +753,7 @@ impl TransactionsDecorator { fn mark_applied(&mut self, transition: &Transition) -> Result<()> { if self.applied.insert(transition.sequence) { - self.write_marker(&format!( - "{APPLIED_MARKER_PREFIX}{}", - transition.sequence - ))?; + self.write_marker(&format!("{APPLIED_MARKER_PREFIX}{}", transition.sequence))?; if transition.sequence > self.applied_sequence { self.applied_sequence = transition.sequence; } @@ -767,12 +774,7 @@ impl TransactionsDecorator { self.applied_sequence = 0; // Read every named link from the log store. - let all_links: Vec = self - .log_store - .all() - .into_iter() - .copied() - .collect(); + let all_links: Vec = self.log_store.all().into_iter().copied().collect(); for link in &all_links { let name = match self.log_store.get_name(link.index)? { Some(value) => value, @@ -891,11 +893,17 @@ impl TransactionsDecorator { } } } - std::fs::create_dir_all(archive_directory) - .with_context(|| format!("failed to create archive dir {}", archive_directory.display()))?; + std::fs::create_dir_all(archive_directory).with_context(|| { + format!( + "failed to create archive dir {}", + archive_directory.display() + ) + })?; let timestamp = now_unix_ms(); - let file_name = - format!("transitions-chunk-{timestamp}-{:032x}.log", new_transaction_id()); + let file_name = format!( + "transitions-chunk-{timestamp}-{:032x}.log", + new_transaction_id() + ); let path = archive_directory.join(file_name); use std::io::Write; let mut file = std::fs::File::create(&path) @@ -961,7 +969,9 @@ mod tests { )); assert!(matches!( LogRetentionPolicy::parse("sized:1000").unwrap(), - LogRetentionPolicy::Sized { max_transitions: 1000 } + LogRetentionPolicy::Sized { + max_transitions: 1000 + } )); match LogRetentionPolicy::parse("chunked:500:/tmp/x").unwrap() { LogRetentionPolicy::Chunked { diff --git a/rust/src/version_control/mod.rs b/rust/src/version_control/mod.rs index cf5eae9..1eec6c5 100644 --- a/rust/src/version_control/mod.rs +++ b/rust/src/version_control/mod.rs @@ -187,6 +187,21 @@ impl VersionControlDecorator { self.transactions.all() } + pub fn search(&self, source: u32, target: u32) -> Option { + self.transactions.search(source, target) + } + + pub fn get_or_create(&mut self, source: u32, target: u32) -> Result { + if let Some(existing) = self.transactions.search(source, target) { + return Ok(existing); + } + self.create(source, target) + } + + pub fn ensure_created(&mut self, id: u32) -> u32 { + self.transactions.ensure_created(id) + } + fn attribute_new_transitions(&mut self, before_seq: i64) -> Result<()> { let after_seq = self.transactions.last_logged_sequence(); if after_seq <= before_seq { @@ -228,9 +243,7 @@ impl VersionControlDecorator { if fork_seq > 0 { let path = self.build_branch_seqs(&parent); if !path.contains(&fork_seq) { - bail!( - "Fork point {fork_seq} is not reachable on branch '{parent}'.", - ); + bail!("Fork point {fork_seq} is not reachable on branch '{parent}'.",); } } self.create_branch(name, Some(parent), fork_seq, fork_seq)?; @@ -261,9 +274,7 @@ impl VersionControlDecorator { let current = self.current_branch.clone(); let path = self.build_branch_seqs(¤t); if sequence > 0 && !path.contains(&sequence) { - bail!( - "Sequence {sequence} is not reachable on branch '{current}'.", - ); + bail!("Sequence {sequence} is not reachable on branch '{current}'.",); } let target_path: Vec = path.iter().copied().filter(|s| *s <= sequence).collect(); self.apply_diff_to(target_path, ¤t)?; @@ -377,11 +388,11 @@ impl VersionControlDecorator { if !self.branches.contains_key(DEFAULT_BRANCH_NAME) { // Pre-existing transitions are attributed to the default branch. for s in 1..=existing { - if !self.transition_branches.contains_key(&s) { - self.transition_branches - .insert(s, DEFAULT_BRANCH_NAME.to_string()); - let marker = - format!("{TRANSITION_PREFIX}{s}:branch={DEFAULT_BRANCH_NAME}"); + if let std::collections::btree_map::Entry::Vacant(entry) = + self.transition_branches.entry(s) + { + entry.insert(DEFAULT_BRANCH_NAME.to_string()); + let marker = format!("{TRANSITION_PREFIX}{s}:branch={DEFAULT_BRANCH_NAME}"); self.write_immutable_marker(&marker)?; } } @@ -507,7 +518,8 @@ impl VersionControlDecorator { if let Some(colon) = rest.find(":branch=") { if let Ok(seq) = rest[..colon].parse::() { let branch_name = &rest[colon + ":branch=".len()..]; - self.transition_branches.insert(seq, branch_name.to_string()); + self.transition_branches + .insert(seq, branch_name.to_string()); } } } @@ -567,9 +579,8 @@ mod tests { #[test] fn make_version_control_database_filename_returns_sibling_path() { - let path = VersionControlDecorator::make_version_control_database_filename( - "/var/data/db.links", - ); + let path = + VersionControlDecorator::make_version_control_database_filename("/var/data/db.links"); assert_eq!(path, PathBuf::from("/var/data/db.versioncontrol.links")); } diff --git a/rust/tests/cli_arguments_tests.rs b/rust/tests/cli_arguments_tests.rs index fc717df..8319eaa 100644 --- a/rust/tests/cli_arguments_tests.rs +++ b/rust/tests/cli_arguments_tests.rs @@ -4,7 +4,7 @@ use link_cli::cli::{Cli, CliCommand}; fn parse_run(args: &[&str]) -> Cli { match Cli::parse_from(args).expect("CLI arguments should parse") { - CliCommand::Run(cli) => cli, + CliCommand::Run(cli) => *cli, other => panic!("expected run command, got {other:?}"), } } diff --git a/rust/tests/transactions_decorator_tests.rs b/rust/tests/transactions_decorator_tests.rs index ac24253..1dbd0c4 100644 --- a/rust/tests/transactions_decorator_tests.rs +++ b/rust/tests/transactions_decorator_tests.rs @@ -15,8 +15,7 @@ use tempfile::NamedTempFile; fn make_tx() -> (TransactionsDecorator, Vec) { let data_file = NamedTempFile::new().expect("create temp file"); let log_file = NamedTempFile::new().expect("create temp file"); - let data_links = - NamedTypesDecorator::new(data_file.path(), false).expect("open data links"); + let data_links = NamedTypesDecorator::new(data_file.path(), false).expect("open data links"); let log_links = NamedTypesDecorator::new(log_file.path(), false).expect("open log links"); let tx = TransactionsDecorator::new( data_links, @@ -38,7 +37,11 @@ fn auto_transaction_records_create_and_update() -> Result<()> { assert_ne!(0, created); let log = tx.log(); - assert_eq!(2, log.len(), "create_and_update must record two transitions"); + assert_eq!( + 2, + log.len(), + "create_and_update must record two transitions" + ); assert_eq!(TransitionKind::Create, log[0].kind); assert_eq!(TransitionKind::Update, log[1].kind); assert_eq!(created, log[0].after.index); @@ -54,7 +57,10 @@ fn rollback_undoes_create() -> Result<()> { assert!(tx.exists(created)); tx.rollback()?; - assert!(!tx.exists(created), "rolled-back create must remove the link"); + assert!( + !tx.exists(created), + "rolled-back create must remove the link" + ); Ok(()) } @@ -117,9 +123,7 @@ fn rollback_undoes_delete() -> Result<()> { #[test] fn sized_retention_drops_oldest_after_applied() -> Result<()> { let (mut tx, _guards) = make_tx(); - tx.set_retention_policy(LogRetentionPolicy::Sized { - max_transitions: 3, - }); + tx.set_retention_policy(LogRetentionPolicy::Sized { max_transitions: 3 }); for _ in 0..5 { tx.create_and_update(0, 0)?; diff --git a/rust/tests/version_control_decorator_tests.rs b/rust/tests/version_control_decorator_tests.rs index 7a031a3..b914212 100644 --- a/rust/tests/version_control_decorator_tests.rs +++ b/rust/tests/version_control_decorator_tests.rs @@ -90,11 +90,17 @@ fn checkout_and_forward_replay_restores_state() -> Result<()> { vc.checkout(after_first)?; assert!(vc.exists(a), "first link must remain after partial rewind"); - assert!(!vc.exists(b), "second link must disappear after partial rewind"); + assert!( + !vc.exists(b), + "second link must disappear after partial rewind" + ); vc.checkout(after_second)?; assert!(vc.exists(a)); - assert!(vc.exists(b), "second link must reappear after forward checkout"); + assert!( + vc.exists(b), + "second link must reappear after forward checkout" + ); Ok(()) } @@ -123,8 +129,14 @@ fn switch_branch_applies_and_rewinds_transitions() -> Result<()> { vc.switch_branch(DEFAULT_BRANCH_NAME)?; assert_eq!(DEFAULT_BRANCH_NAME, vc.current_branch()); - assert!(vc.exists(a), "main-branch link must remain after switching back"); - assert!(!vc.exists(b), "feature-branch link must disappear after switching back to main"); + assert!( + vc.exists(a), + "main-branch link must remain after switching back" + ); + assert!( + !vc.exists(b), + "feature-branch link must disappear after switching back to main" + ); assert_eq!(head_before_branch, vc.current_sequence()); vc.switch_branch("feature")?; @@ -200,10 +212,7 @@ fn recover_rebuilds_state_from_branches_store() -> Result<()> { false, )?; let reopened = VersionControlDecorator::new(tx, vc_links, false)?; - assert!(reopened - .list_branches() - .iter() - .any(|b| b.name == "feature")); + assert!(reopened.list_branches().iter().any(|b| b.name == "feature")); assert!(reopened.try_get_tag("checkpoint").is_some()); Ok(()) } From 19ada23e9b909277bf71304024c25ceb84aeccb1 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 14:11:30 +0000 Subject: [PATCH 11/17] test(rust): add CLI integration tests for transactions and version-control Covers both flag parsing (transactions/vc family + inline-value forms, implies-transactions semantics, rejection of bad branch-from values) and end-to-end binary behavior (sidecar creation, --log output, --list-branches/--list-tags, --tag round-trip, --commit-mode and --retention error paths, and the R8/R17 no-flags no-sidecar guarantee). --- rust/tests/cli_arguments_tests.rs | 117 ++++++++++ rust/tests/cli_transactions_and_vc_tests.rs | 230 ++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 rust/tests/cli_transactions_and_vc_tests.rs diff --git a/rust/tests/cli_arguments_tests.rs b/rust/tests/cli_arguments_tests.rs index 8319eaa..08c780c 100644 --- a/rust/tests/cli_arguments_tests.rs +++ b/rust/tests/cli_arguments_tests.rs @@ -108,3 +108,120 @@ fn rejects_extra_positional_queries() { assert!(error.to_string().contains("unexpected extra positional")); } + +#[test] +fn parses_transactions_flag_family() { + let cli = parse_run(&[ + "clink", + "--transactions", + "--transactions-file", + "trans.links", + "--commit-mode", + "async", + "--retention", + "sized:128", + "--log", + ]); + + assert!(cli.transactions); + assert_eq!(cli.transactions_file.as_deref(), Some("trans.links")); + assert_eq!(cli.commit_mode.as_deref(), Some("async")); + assert_eq!(cli.retention.as_deref(), Some("sized:128")); + assert!(cli.show_log); + assert!(cli.transactions_requested()); + assert!(!cli.vc_requested()); +} + +#[test] +fn parses_inline_transactions_flag_values() { + let cli = parse_run(&[ + "clink", + "--transactions=true", + "--transactions-file=tx.links", + "--commit-mode=sync", + "--retention=chunked:64:/tmp/archive", + "--log=true", + ]); + + assert!(cli.transactions); + assert_eq!(cli.transactions_file.as_deref(), Some("tx.links")); + assert_eq!(cli.commit_mode.as_deref(), Some("sync")); + assert_eq!(cli.retention.as_deref(), Some("chunked:64:/tmp/archive")); + assert!(cli.show_log); +} + +#[test] +fn parses_version_control_flag_family() { + let cli = parse_run(&[ + "clink", + "--vc", + "--vc-file", + "vc.links", + "--branch", + "feature", + "--branch-from", + "3", + "--checkout", + "main", + "--tag", + "release=2", + "--list-branches", + "--list-tags", + ]); + + assert!(cli.vc); + assert_eq!(cli.vc_file.as_deref(), Some("vc.links")); + assert_eq!(cli.branch.as_deref(), Some("feature")); + assert_eq!(cli.branch_from, Some(3)); + assert_eq!(cli.checkout.as_deref(), Some("main")); + assert_eq!(cli.tag.as_deref(), Some("release=2")); + assert!(cli.list_branches); + assert!(cli.list_tags); + assert!(cli.vc_requested()); + // version-control implies transactions. + assert!(cli.transactions_requested()); +} + +#[test] +fn parses_inline_version_control_flag_values() { + let cli = parse_run(&[ + "clink", + "--vc=true", + "--vc-file=vc.bin", + "--branch=topic", + "--branch-from=7", + "--checkout=v1.0", + "--tag=v2.0", + "--list-branches=false", + "--list-tags=true", + ]); + + assert!(cli.vc); + assert_eq!(cli.vc_file.as_deref(), Some("vc.bin")); + assert_eq!(cli.branch.as_deref(), Some("topic")); + assert_eq!(cli.branch_from, Some(7)); + assert_eq!(cli.checkout.as_deref(), Some("v1.0")); + assert_eq!(cli.tag.as_deref(), Some("v2.0")); + assert!(!cli.list_branches); + assert!(cli.list_tags); +} + +#[test] +fn defaults_have_no_transactions_or_vc_requested() { + let cli = parse_run(&["clink"]); + + assert!(!cli.transactions_requested()); + assert!(!cli.vc_requested()); + assert!(!cli.transactions); + assert!(!cli.vc); +} + +#[test] +fn rejects_invalid_branch_from_value() { + let error = Cli::parse_from(["clink", "--branch-from", "not-a-number"]) + .expect_err("invalid branch-from should fail"); + assert!( + error.to_string().contains("invalid sequence value"), + "unexpected error message: {error}" + ); +} diff --git a/rust/tests/cli_transactions_and_vc_tests.rs b/rust/tests/cli_transactions_and_vc_tests.rs new file mode 100644 index 0000000..95278e9 --- /dev/null +++ b/rust/tests/cli_transactions_and_vc_tests.rs @@ -0,0 +1,230 @@ +//! End-to-end CLI tests for the transactions and version-control +//! layers wired up in main.rs. Exercises the option-implies-option +//! semantics (R8/R17) and validates the visible side effects of each +//! family of flags. + +use anyhow::{ensure, Result}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::tempdir; + +fn clink() -> Command { + Command::new(env!("CARGO_BIN_EXE_clink")) +} + +fn ensure_success(output: &Output) -> Result<()> { + ensure!( + output.status.success(), + "clink failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +fn run_query(db: &Path, query: &str, extra: &[&str]) -> Result { + let mut command = clink(); + command.arg("--db").arg(db); + for arg in extra { + command.arg(arg); + } + Ok(command.arg(query).output()?) +} + +fn transitions_sidecar_for(db: &Path) -> PathBuf { + let stem = db.file_stem().unwrap().to_string_lossy().into_owned(); + db.parent() + .unwrap() + .join(format!("{stem}.transitions.links")) +} + +fn vc_sidecar_for(db: &Path) -> PathBuf { + let stem = db.file_stem().unwrap().to_string_lossy().into_owned(); + db.parent() + .unwrap() + .join(format!("{stem}.versioncontrol.links")) +} + +#[test] +fn transactions_flag_creates_transitions_sidecar() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + + let out = run_query(&db, "() ((1 1) (2 2))", &["--transactions"])?; + ensure_success(&out)?; + + let sidecar = transitions_sidecar_for(&db); + assert!( + sidecar.exists(), + "default transitions sidecar should be created next to the db" + ); + Ok(()) +} + +#[test] +fn transactions_log_flag_prints_recorded_transitions() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + + // Apply two link creations. + ensure_success(&run_query( + &db, + "() ((1 1) (2 2))", + &["--transactions"], + )?)?; + + // Then print the log. + let out = run_query(&db, "", &["--transactions", "--log"])?; + ensure_success(&out)?; + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.lines().count() >= 2, + "log should contain at least one line per applied transition; got:\n{stdout}" + ); + assert!( + stdout.contains("Create") || stdout.contains("Update"), + "log should mention transition kind; got:\n{stdout}" + ); + Ok(()) +} + +#[test] +fn explicit_transactions_file_is_honored() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + let log = dir.path().join("custom-log.links"); + + ensure_success(&run_query( + &db, + "() ((1 1))", + &["--transactions-file", log.to_str().unwrap()], + )?)?; + + assert!(log.exists(), "explicit transactions file must be created"); + assert!( + !transitions_sidecar_for(&db).exists(), + "default sidecar should NOT be created when --transactions-file is given" + ); + Ok(()) +} + +#[test] +fn vc_flag_creates_version_control_sidecar() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + + ensure_success(&run_query(&db, "() ((1 1))", &["--vc"])?)?; + assert!( + vc_sidecar_for(&db).exists(), + "version-control sidecar should be created next to the db" + ); + Ok(()) +} + +#[test] +fn vc_list_branches_shows_default_branch() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + + ensure_success(&run_query(&db, "() ((1 1))", &["--vc"])?)?; + + let out = run_query(&db, "", &["--vc", "--list-branches"])?; + ensure_success(&out)?; + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("main"), + "list-branches should list the default 'main' branch; got:\n{stdout}" + ); + assert!( + stdout.contains('*'), + "list-branches should mark the current branch with '*'; got:\n{stdout}" + ); + Ok(()) +} + +#[test] +fn vc_branch_then_switch_back_creates_new_branch() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + + ensure_success(&run_query(&db, "() ((1 1))", &["--vc"])?)?; + ensure_success(&run_query( + &db, + "() ((2 2))", + &["--vc", "--branch", "feature"], + )?)?; + + let out = run_query(&db, "", &["--vc", "--list-branches"])?; + ensure_success(&out)?; + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("main")); + assert!( + stdout.contains("feature"), + "feature branch should be created; got:\n{stdout}" + ); + Ok(()) +} + +#[test] +fn vc_tag_then_list_tags_round_trip() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + + ensure_success(&run_query(&db, "() ((1 1))", &["--vc"])?)?; + ensure_success(&run_query(&db, "", &["--vc", "--tag", "v1"])?)?; + + let out = run_query(&db, "", &["--vc", "--list-tags"])?; + ensure_success(&out)?; + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("v1"), + "list-tags should include the new 'v1' tag; got:\n{stdout}" + ); + Ok(()) +} + +#[test] +fn invalid_commit_mode_value_is_rejected() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + let out = run_query(&db, "() ((1 1))", &["--commit-mode", "bogus"])?; + assert!( + !out.status.success(), + "invalid commit-mode value should cause the binary to exit non-zero" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--commit-mode"), + "stderr should mention --commit-mode; got:\n{stderr}" + ); + Ok(()) +} + +#[test] +fn invalid_retention_value_is_rejected() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + let out = run_query(&db, "() ((1 1))", &["--retention", "bogus"])?; + assert!( + !out.status.success(), + "invalid retention value should cause the binary to exit non-zero" + ); + Ok(()) +} + +#[test] +fn no_flags_does_not_create_transactions_sidecar() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("data.links"); + ensure_success(&run_query(&db, "() ((1 1))", &[])?)?; + assert!( + !transitions_sidecar_for(&db).exists(), + "running without transactions flags must NOT create a transitions sidecar (R8)" + ); + assert!( + !vc_sidecar_for(&db).exists(), + "running without version-control flags must NOT create a vc sidecar (R17)" + ); + Ok(()) +} From 7c646a0d52118374eb9a89edc648366a6f7b3e16 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 14:15:19 +0000 Subject: [PATCH 12/17] docs(examples): add runnable transactions and version-control demos One README + two shell scripts per layer, mirroring each other across C# and Rust. The scripts drive the same flag surface end-to-end so the demos exercise sidecar creation, log inspection, commit modes, all three retention policies, tags, branches, and time-travel checkout. Each script runs against a fresh tmp directory so multiple runs stay isolated. All four scripts have been smoke-tested locally and produce the expected output on both implementations. --- examples/transactions/README.md | 48 ++++++++++++++++++++ examples/transactions/run-csharp.sh | 60 +++++++++++++++++++++++++ examples/transactions/run-rust.sh | 53 ++++++++++++++++++++++ examples/version-control/README.md | 54 ++++++++++++++++++++++ examples/version-control/run-csharp.sh | 62 ++++++++++++++++++++++++++ examples/version-control/run-rust.sh | 56 +++++++++++++++++++++++ 6 files changed, 333 insertions(+) create mode 100644 examples/transactions/README.md create mode 100755 examples/transactions/run-csharp.sh create mode 100755 examples/transactions/run-rust.sh create mode 100644 examples/version-control/README.md create mode 100755 examples/version-control/run-csharp.sh create mode 100755 examples/version-control/run-rust.sh diff --git a/examples/transactions/README.md b/examples/transactions/README.md new file mode 100644 index 0000000..bd202c8 --- /dev/null +++ b/examples/transactions/README.md @@ -0,0 +1,48 @@ +# Transactions layer — examples + +This folder contains small, runnable demonstrations of the optional +**transactions** decorator added in issue +[#94](https://github.com/link-foundation/link-cli/issues/94). Both the +C# and the Rust CLIs expose the same flag surface, so the same shell +script works for either binary — pick the one you have installed: + +| Binary | Invocation | +|--------|------------| +| C# | `dotnet run --project csharp/Foundation.Data.Doublets.Cli --` | +| Rust | `cargo run --manifest-path rust/Cargo.toml --` | + +## Scripts + +| File | What it shows | +|------|---------------| +| `run-csharp.sh` | End-to-end transactions demo using the C# `clink` binary | +| `run-rust.sh` | End-to-end transactions demo using the Rust `clink` binary | +| `README.md` | This file | + +## What the demo does + +1. Creates two links inside an explicit `--transactions` session — the + writes go to `data.links` and a side-car transitions log is written + to `data.transitions.links`. +2. Prints the resulting transitions log with `--log`, so you can see + the `Create / Update / Delete` records, their sequence numbers, the + transaction ids that grouped them, and the (index, source, target) + before/after states. +3. Demonstrates each commit mode (`--commit-mode sync` and + `--commit-mode async`) and each retention policy (`--retention + infinite`, `--retention sized:N`, `--retention chunked:N:/path`). + +## Key flags + +```text +--transactions Enable the transactions decorator +--transactions-file Explicit transitions log path (implies --transactions) +--commit-mode Commit mode (implies --transactions) +--retention Retention policy (implies --transactions) + spec ∈ { infinite | sized:N | chunked:N:DIR } +--log Print transitions log and exit (implies --transactions) +``` + +When *no* transaction flag is used the bare `clink` behaviour is +unchanged: no transitions file is written and no extra runtime cost is +paid (R8 / R9 / R17 from the requirements doc). diff --git a/examples/transactions/run-csharp.sh b/examples/transactions/run-csharp.sh new file mode 100755 index 0000000..f1b93a7 --- /dev/null +++ b/examples/transactions/run-csharp.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Demonstrates the optional transactions layer with the C# `clink` binary. +# +# Usage: +# ./examples/transactions/run-csharp.sh +# +# Builds and runs the binary from the csharp/ workspace. All artifacts +# land in a fresh tmp directory so multiple runs stay isolated. + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +clink() { + dotnet run \ + --project "$repo_root/csharp/Foundation.Data.Doublets.Cli" \ + --configuration Release \ + --no-restore \ + -- "$@" +} + +cd "$work_dir" + +dotnet restore "$repo_root/csharp/Foundation.Data.Doublets.Cli" > /dev/null + +echo "=== 1. Create two links with --transactions (sync, default retention) ===" +clink --db data.links --transactions --auto-create-missing-references "() ((1 1) (2 2))" +ls -1 *.links + +echo +echo "=== 2. Inspect the transitions log ===" +clink --db data.links --log + +echo +echo "=== 3. Create another link with explicit async commits and sized retention ===" +clink \ + --db data.links \ + --auto-create-missing-references \ + --commit-mode async \ + --retention sized:128 \ + "() ((3 3))" + +echo +echo "=== 4. Print the log again — sequence numbers grew ===" +clink --db data.links --log + +echo +echo "=== 5. Try a chunked retention archive (every 1 transition rolls over) ===" +mkdir -p "$work_dir/archive" +clink \ + --db data.links \ + --auto-create-missing-references \ + --retention "chunked:1:$work_dir/archive" \ + "() ((4 4))" +ls -1 "$work_dir/archive" || true + +echo +echo "Demo complete. Working dir: $work_dir" diff --git a/examples/transactions/run-rust.sh b/examples/transactions/run-rust.sh new file mode 100755 index 0000000..7916e5d --- /dev/null +++ b/examples/transactions/run-rust.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Demonstrates the optional transactions layer with the Rust `clink` binary. +# +# Usage: +# ./examples/transactions/run-rust.sh +# +# Builds and runs the binary from the rust/ workspace. All artifacts are +# written into a fresh tmp directory so multiple runs do not pollute each +# other. + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +clink() { + cargo run --manifest-path "$repo_root/rust/Cargo.toml" --quiet -- "$@" +} + +cd "$work_dir" + +echo "=== 1. Create two links with --transactions (sync, default retention) ===" +clink --db data.links --transactions "() ((1 1) (2 2))" +ls -1 *.links + +echo +echo "=== 2. Inspect the transitions log ===" +clink --db data.links --log + +echo +echo "=== 3. Create another link with explicit async commits and sized retention ===" +clink \ + --db data.links \ + --commit-mode async \ + --retention sized:128 \ + "() ((3 3))" + +echo +echo "=== 4. Print the log again — sequence numbers grew ===" +clink --db data.links --log + +echo +echo "=== 5. Try a chunked retention archive (every 1 transition rolls over) ===" +mkdir -p "$work_dir/archive" +clink \ + --db data.links \ + --retention "chunked:1:$work_dir/archive" \ + "() ((4 4))" +ls -1 "$work_dir/archive" || true + +echo +echo "Demo complete. Working dir: $work_dir" diff --git a/examples/version-control/README.md b/examples/version-control/README.md new file mode 100644 index 0000000..9440314 --- /dev/null +++ b/examples/version-control/README.md @@ -0,0 +1,54 @@ +# Version-control layer — examples + +This folder contains small, runnable demonstrations of the optional +**version-control** decorator added in issue +[#94](https://github.com/link-foundation/link-cli/issues/94). The +decorator sits *above* the transactions layer and adds time travel, +branching, and tagging over the recorded transitions log. Both the +C# and the Rust CLIs expose the same flag surface. + +| Binary | Invocation | +|--------|------------| +| C# | `dotnet run --project csharp/Foundation.Data.Doublets.Cli --` | +| Rust | `cargo run --manifest-path rust/Cargo.toml --` | + +## Scripts + +| File | What it shows | +|------|---------------| +| `run-csharp.sh` | End-to-end version-control demo using the C# `clink` binary | +| `run-rust.sh` | End-to-end version-control demo using the Rust `clink` binary | +| `README.md` | This file | + +## What the demo does + +1. Creates a few links on the default `main` branch. +2. Tags the current head as `v1`. +3. Forks a new branch `feature` from the current head, applies more + changes there, then tags `v2`. +4. Switches back to `main` — the `feature`-only transitions are rolled + back so the data store again matches `main`. +5. `--checkout v1` rewinds the live store further to the `v1` tag. +6. Lists branches and tags. + +## Key flags + +```text +--vc Enable the version-control decorator + (implies --transactions) +--vc-file Explicit version-control store path + (default: .versioncontrol.links) +--branch Switch to branch, create it if --branch-from + is also passed. Implies --vc. +--branch-from Fork point when creating a branch +--checkout Time-travel to a sequence number or named tag + Implies --vc. +--tag Tag the current head, or a specific seq. + Implies --vc. +--list-branches Print all branches and exit +--list-tags Print all tags and exit +``` + +When neither `--vc` nor any version-control flag is used the bare CLI +behaviour is unchanged: no `versioncontrol.links` sidecar is created +(R17). diff --git a/examples/version-control/run-csharp.sh b/examples/version-control/run-csharp.sh new file mode 100755 index 0000000..117eda6 --- /dev/null +++ b/examples/version-control/run-csharp.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Demonstrates the optional version-control layer with the C# `clink` binary. +# +# Usage: +# ./examples/version-control/run-csharp.sh + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +clink() { + dotnet run \ + --project "$repo_root/csharp/Foundation.Data.Doublets.Cli" \ + --configuration Release \ + --no-restore \ + -- "$@" +} + +cd "$work_dir" + +dotnet restore "$repo_root/csharp/Foundation.Data.Doublets.Cli" > /dev/null + +echo "=== 1. Apply two links on the default 'main' branch (creates VC sidecar) ===" +clink --db data.links --vc --auto-create-missing-references "() ((1 1) (2 2))" +ls -1 *.links + +echo +echo "=== 2. Tag the current head as v1 ===" +clink --db data.links --vc --tag v1 + +echo +echo "=== 3. Fork branch 'feature' and apply changes there ===" +clink --db data.links --vc --auto-create-missing-references --branch feature --branch-from 2 "() ((3 3))" + +echo +echo "=== 4. Tag this branch head as v2 ===" +clink --db data.links --vc --tag v2 + +echo +echo "=== 5. List branches (current marked with *) ===" +clink --db data.links --vc --list-branches + +echo +echo "=== 6. List tags ===" +clink --db data.links --vc --list-tags + +echo +echo "=== 7. Switch back to main — feature transitions are rolled back ===" +clink --db data.links --vc --branch main + +echo +echo "=== 8. Show full transitions log so far ===" +clink --db data.links --vc --log + +echo +echo "=== 9. Checkout the v1 tag (rewind further to seq=2) ===" +clink --db data.links --vc --checkout v1 + +echo +echo "Demo complete. Working dir: $work_dir" diff --git a/examples/version-control/run-rust.sh b/examples/version-control/run-rust.sh new file mode 100755 index 0000000..44f24db --- /dev/null +++ b/examples/version-control/run-rust.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Demonstrates the optional version-control layer with the Rust `clink` binary. +# +# Usage: +# ./examples/version-control/run-rust.sh + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +clink() { + cargo run --manifest-path "$repo_root/rust/Cargo.toml" --quiet -- "$@" +} + +cd "$work_dir" + +echo "=== 1. Apply two links on the default 'main' branch (creates VC sidecar) ===" +clink --db data.links --vc "() ((1 1) (2 2))" +ls -1 *.links + +echo +echo "=== 2. Tag the current head as v1 ===" +clink --db data.links --vc --tag v1 + +echo +echo "=== 3. Fork branch 'feature' and apply changes there ===" +clink --db data.links --vc --branch feature --branch-from 2 "() ((3 3))" + +echo +echo "=== 4. Tag this branch head as v2 ===" +clink --db data.links --vc --tag v2 + +echo +echo "=== 5. List branches (current marked with *) ===" +clink --db data.links --vc --list-branches + +echo +echo "=== 6. List tags ===" +clink --db data.links --vc --list-tags + +echo +echo "=== 7. Switch back to main — feature transitions are rolled back ===" +clink --db data.links --vc --branch main + +echo +echo "=== 8. Show full transitions log so far ===" +clink --db data.links --vc --log + +echo +echo "=== 9. Checkout the v1 tag (rewind further to seq=2) ===" +clink --db data.links --vc --checkout v1 + +echo +echo "Demo complete. Working dir: $work_dir" From 330a012fda22c6440b311c3fd1b07bc846d99117 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 14:19:10 +0000 Subject: [PATCH 13/17] docs(issue-94): document optional transactions and version-control layers Adds documentation coverage for the new --transactions and --vc flag families across REQUIREMENTS.md, ARCHITECTURE.md, HOW-IT-WORKS.md, and both per-implementation READMEs so users can discover the layers, find the demo scripts under examples/, and understand the decorator stack (LinkStorage -> NamedTypesDecorator -> TransactionsDecorator -> VersionControlDecorator). --- csharp/README.md | 25 ++++++++++++++++ docs/ARCHITECTURE.md | 62 ++++++++++++++++++++++++++++++++++++++- docs/HOW-IT-WORKS.md | 69 ++++++++++++++++++++++++++++++++++++++++++++ docs/REQUIREMENTS.md | 2 ++ rust/README.md | 24 +++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) diff --git a/csharp/README.md b/csharp/README.md index 9062ddb..96f7494 100644 --- a/csharp/README.md +++ b/csharp/README.md @@ -51,6 +51,31 @@ transformation triggers with `--always`, `--once`, `--never`, `--triggers`, the public library, so other .NET applications can call into the same processors directly. +### Optional Transactions and Version Control + +Pass `--transactions` (or any flag in the family — `--transactions-file`, +`--commit-mode`, `--retention`, `--log`) to record each Create/Update/Delete +as a reversible transition in a sidecar doublets store. Pass `--vc` +(or `--vc-file`, `--branch`, `--branch-from`, `--checkout`, `--tag`, +`--list-branches`, `--list-tags`) to add a version-control layer over the +recorded transitions log: + +```bash +# Record reversible transitions into data.transitions.links +clink --db data.links --transactions --auto-create-missing-references '() ((1 1))' +clink --db data.links --log + +# Branch and tag on top of the transitions log +clink --db data.links --vc --tag v1 +clink --db data.links --vc --branch feature --branch-from 1 +clink --db data.links --vc --list-branches +``` + +`BeginTransaction()` / `Commit()` / `Rollback()` are also exposed on the +library API for explicit batches. End-to-end demo scripts live in +[`examples/transactions/`](../examples/transactions) and +[`examples/version-control/`](../examples/version-control). + ## Develop ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4446c9..0ab060e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,12 @@ Key files: - `LinoDatabaseOutput.cs`: database export, change formatting, and structure formatting. - `PersistentTransformationDecorator.cs`: stored trigger support. +- `TransactionsDecorator.cs`: optional transactions layer that records each + Create/Update/Delete as a reversible transition into a sidecar doublets + store. +- `VersionControlDecorator.cs`: optional version-control layer that sits above + the transactions decorator and provides branching, tagging, and time-travel + checkout. Main C# dependencies: @@ -64,6 +70,10 @@ Key files: - `rust/src/named_types.rs`: names sidecar storage. - `rust/src/lino_database_input.rs`: `.lino` import. - `rust/src/sequences/`: Unicode sequence conversion and related parity code. +- `rust/src/transactions/`: optional transactions decorator and the + `TransitionLog`, retention-policy, and commit-mode types. +- `rust/src/version_control/`: optional version-control decorator with + branching, tagging, and time-travel checkout. Main Rust dependencies: @@ -109,6 +119,8 @@ the primary filename. | `.links` | C# and Rust | Primary numeric links database. | | `.names.links` | C# and Rust | Mapping between string names and numeric link references. | | `.triggers.links` | C# | Persistent trigger definitions when triggers are not embedded. | +| `.transitions.links` | C# and Rust | Optional transitions log (created when `--transactions` is requested). | +| `.versioncontrol.links` | C# and Rust | Optional version-control branches/tags store (created when `--vc` is requested). | For `graph.links`, the default names file is `graph.names.links`, and the default triggers file is `graph.triggers.links`. @@ -126,7 +138,55 @@ The high-level pipeline is the same across C# and Rust: 7. Apply writes to storage. 8. Format requested output: before, changes, after, structure, import/export. -## Persistent Transformation Triggers +## Optional Transactions Layer + +The `TransactionsDecorator` (C#) and `transactions::TransactionsDecorator` +(Rust) wrap a `NamedTypesDecorator` and record each Create / Update / +Delete as a reversible `Transition` (before + after doublet state, plus a +sequence number, transaction id, and timestamp). Transitions are serialized +as names inside a *second* doublets store — the transitions log itself is +also a links database, so the same storage, recovery, and tooling apply. + +Composition: `LinkStorage → NamedTypesDecorator → TransactionsDecorator`. + +Public surface: + +- `Create / Update / Delete / CreateAndUpdate` — recorded automatically. +- `BeginTransaction()` — explicit batch with `Commit()` and `Rollback()`; + drop without commit rolls back automatically. +- `Log()` — read the recorded transitions. +- Three retention policies: `infinite`, `sized:` (drop oldest applied), + and `chunked::` (archive oldest applied to rolling files). +- Two commit modes: `sync` (default — flushes data side-effects before + returning) and `async` (durably persists the log first). +- Crash recovery: on open, every committed-but-not-applied transition is + replayed against the underlying store. + +When no transaction flag is passed at the CLI and the decorator is not +instantiated through the library API, the existing `NamedTypesDecorator` +behaviour is preserved exactly — no transitions file is written and no +extra runtime cost is paid. + +## Optional Version-Control Layer + +The `VersionControlDecorator` (C#) and `version_control::VersionControlDecorator` +(Rust) sit *above* the transactions decorator and add three operations +over the recorded transitions log: + +- **Branching** — `Branch(name, forkSeq?)` creates a new branch that + points at an existing sequence number; `SwitchBranch(name)` rewinds or + replays transitions so the live store matches the target branch's head. +- **Tagging** — `Tag(name, seq?)` records a stable name for any + sequence number. +- **Time-travel checkout** — `Checkout(seq)` rewinds (or replays) the + live store to an arbitrary sequence number. + +Composition: +`LinkStorage → NamedTypesDecorator → TransactionsDecorator → VersionControlDecorator`. + +Branch metadata, tags, current-branch, and applied-seq markers are all +stored inside a second sidecar doublets store so version-control state +is itself a links database. C# trigger support stores transformation queries as links. `--always` and `--once` create trigger records, `--never` removes matching records, and normal diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 1d5a26d..57ee0fc 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -171,6 +171,75 @@ write operations. The trigger schema is link-backed, using named points such as `Always`, `Once`, `Condition`, and `Substitution`. +## Optional Transactions + +Pass `--transactions` (or any flag in the family — `--transactions-file`, +`--commit-mode`, `--retention`, `--log`) to wrap the storage in a +`TransactionsDecorator`. Every Create / Update / Delete then writes a +*reversible* transition into a sidecar doublets store. The default +sidecar is named like the database: + +```text +data.links # primary store +data.transitions.links # transitions log (sidecar) +``` + +Inspect the log with `--log`: + +```text +1 2026-05-20T14:14:03 Create cf1f... (0,0,0) -> (1,1,1) +2 2026-05-20T14:14:03 Create ca32... (0,0,0) -> (2,2,2) +``` + +The library exposes `BeginTransaction()` returning a handle with +`Commit()` and `Rollback()`. A handle dropped without commit rolls back +automatically. + +Retention policies (`--retention`): + +- `infinite` — keep every transition (default). +- `sized:` — drop the oldest *applied* transitions once the live log + exceeds N. Never drops un-applied transitions. +- `chunked::` — archive the oldest N applied transitions into a + rolling file inside `DIR` once the live log reaches N. + +Commit modes (`--commit-mode`): + +- `sync` (default) — flushes data side-effects before commit returns. +- `async` — durably persists the log first, then applies side-effects. + +Recovery: on open, every committed-but-not-applied transition is +replayed against the underlying store. + +When no transaction flag is passed, behaviour is byte-identical to the +bare CLI — no sidecar is written. + +## Optional Version Control + +Pass `--vc` (or any flag in the family) to add a version-control layer +above transactions. The decorator stores branches, tags, current-branch, +and applied-seq markers inside a second sidecar: + +```text +data.versioncontrol.links +``` + +Operations: + +- `--branch [--branch-from ]` — switch to a branch, creating + it forked off `` when missing. +- `--tag ` or `--tag =` — name a sequence number. +- `--checkout ` — time-travel the live store backward or + forward to a sequence number or named tag. +- `--list-branches` / `--list-tags` — print and exit. + +Switching branches rewinds (undoes) transitions that are not part of the +target branch and replays transitions that are. Checkout to seq=0 +rewinds everything; checkout to a higher seq replays as needed. + +When no version-control flag is passed, no `versioncontrol.links` +sidecar is created. + ## Browser Runtime The WebAssembly workbench uses the Rust query processor in the browser. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 44074e4..7a7b5fc 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -43,6 +43,8 @@ the core CLI behavior and is also used by the WebAssembly browser workbench. | Import a database from LiNo. | #25, #43 | `--in`, `--import`, and `--lino-input` read a LiNo file before query execution. | | Format a link structure. | #19, #48 | `--structure` recursively formats the left branch with indexes preserved. | | Store persistent transformations. | #3, #55 | C# supports `--always`, `--once`, `--never`, `--triggers`, `--triggers-file`, and `--embed-triggers`. | +| Optional transactions layer. | #94 | C# and Rust expose `--transactions`, `--transactions-file`, `--commit-mode`, `--retention`, and `--log`. Each Create/Update/Delete is recorded as a reversible transition in a doublets-store sidecar; explicit `BeginTransaction()` / `Commit()` / `Rollback()` APIs are available in both libraries. Three retention policies are supported: `infinite`, `sized:`, and `chunked::`. Crash recovery replays committed transitions on the next open. When no flag is passed, behaviour is identical to the bare CLI (no sidecar, no cost). | +| Optional version-control layer. | #94 | C# and Rust expose `--vc`, `--vc-file`, `--branch`, `--branch-from`, `--checkout`, `--tag`, `--list-branches`, and `--list-tags`. The version-control decorator sits above the transactions decorator and adds branching (named DAG of branches), tagging (named pointers to sequence numbers), and time-travel checkout (rewind/replay transitions). When no flag is passed, no version-control sidecar is created. | | Separate code by implementation language. | #63, #64, #77, #79 | C# code and release helpers live under `csharp/`; Rust code, release helpers, and the WebAssembly wrapper crate live under `rust/`; the browser app and JavaScript lockfile live under `js/`. | | Provide Rust parity for core behavior. | #63, #67, #68 | Rust mirrors query processing, names, import/export, structure formatting, and Unicode sequence support. | | Run in a browser. | #12, #52, #69, #70 | The Rust query processor is wrapped with `wasm-bindgen` and surfaced through a React/Vite workbench. | diff --git a/rust/README.md b/rust/README.md index 3fdf273..d7cfe9b 100644 --- a/rust/README.md +++ b/rust/README.md @@ -34,6 +34,30 @@ GitHub Pages alongside the C# DocFX site by `.github/workflows/docs.yml`. clink '() ((1 1))' --changes --after ``` +### Optional Transactions and Version Control + +Pass `--transactions` (or any flag in the family — `--transactions-file`, +`--commit-mode`, `--retention`, `--log`) to record each Create/Update/Delete +as a reversible transition in a sidecar doublets store. Pass `--vc` +(or `--vc-file`, `--branch`, `--branch-from`, `--checkout`, `--tag`, +`--list-branches`, `--list-tags`) to add a version-control layer over the +recorded transitions log: + +```bash +# Record reversible transitions into data.transitions.links +clink --db data.links --transactions --auto-create-missing-references '() ((1 1))' +clink --db data.links --log + +# Branch and tag on top of the transitions log +clink --db data.links --vc --tag v1 +clink --db data.links --vc --branch feature --branch-from 1 +clink --db data.links --vc --list-branches +``` + +End-to-end demo scripts live in +[`examples/transactions/`](../examples/transactions) and +[`examples/version-control/`](../examples/version-control). + ## Develop ```bash From b9ca9370586c2340b73f8b3beeae746047ebde11 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 14:20:03 +0000 Subject: [PATCH 14/17] style(rust): reformat cli_transactions_and_vc_tests with cargo fmt --- rust/tests/cli_transactions_and_vc_tests.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/rust/tests/cli_transactions_and_vc_tests.rs b/rust/tests/cli_transactions_and_vc_tests.rs index 95278e9..c70c408 100644 --- a/rust/tests/cli_transactions_and_vc_tests.rs +++ b/rust/tests/cli_transactions_and_vc_tests.rs @@ -68,11 +68,7 @@ fn transactions_log_flag_prints_recorded_transitions() -> Result<()> { let db = dir.path().join("data.links"); // Apply two link creations. - ensure_success(&run_query( - &db, - "() ((1 1) (2 2))", - &["--transactions"], - )?)?; + ensure_success(&run_query(&db, "() ((1 1) (2 2))", &["--transactions"])?)?; // Then print the log. let out = run_query(&db, "", &["--transactions", "--log"])?; From 7e9698b8b1c065246a18047bf5044e9e87b3278e Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 14:21:25 +0000 Subject: [PATCH 15/17] chore(issue-94): add changeset and changelog fragment for transactions and version-control Adds a C# changeset (minor) and Rust changelog fragment (minor) so the release automation can roll the optional transactions and version-control layers into the next release notes for both packages. --- ...issue-94-transactions-and-version-control.md | 16 ++++++++++++++++ ...issue_94_transactions_and_version_control.md | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 csharp/.changeset/issue-94-transactions-and-version-control.md create mode 100644 rust/changelog.d/20260520_120000_issue_94_transactions_and_version_control.md diff --git a/csharp/.changeset/issue-94-transactions-and-version-control.md b/csharp/.changeset/issue-94-transactions-and-version-control.md new file mode 100644 index 0000000..a7ad643 --- /dev/null +++ b/csharp/.changeset/issue-94-transactions-and-version-control.md @@ -0,0 +1,16 @@ +--- +'Foundation.Data.Doublets.Cli': minor +--- + +Added optional transactions and version-control layers (issue #94). The +new `TransactionsDecorator` records each Create/Update/Delete as a +reversible transition in a sidecar doublets store and exposes +`BeginTransaction()` / `Commit()` / `Rollback()` plus three retention +policies (`infinite`, `sized:`, `chunked::`) and two commit +modes (`sync`, `async`). The new `VersionControlDecorator` adds +branching, tagging, and time-travel checkout over that log. The CLI +surfaces both layers through `--transactions`, `--transactions-file`, +`--commit-mode`, `--retention`, `--log`, `--vc`, `--vc-file`, +`--branch`, `--branch-from`, `--checkout`, `--tag`, `--list-branches`, +and `--list-tags`. When no flag is passed, behaviour is byte-identical +to the existing CLI — no sidecar is written and no extra cost is paid. diff --git a/rust/changelog.d/20260520_120000_issue_94_transactions_and_version_control.md b/rust/changelog.d/20260520_120000_issue_94_transactions_and_version_control.md new file mode 100644 index 0000000..b8272d1 --- /dev/null +++ b/rust/changelog.d/20260520_120000_issue_94_transactions_and_version_control.md @@ -0,0 +1,17 @@ +--- +bump: minor +--- + +Added optional transactions and version-control layers (issue #94). +`transactions::TransactionsDecorator` records each Create/Update/Delete +as a reversible transition in a sidecar doublets store and exposes +`begin_transaction()` / `commit()` / `rollback()` plus three retention +policies (`infinite`, `sized:`, `chunked::`) and two commit +modes (`sync`, `async`). `version_control::VersionControlDecorator` +adds branching, tagging, and time-travel checkout over the recorded +log. The `clink` binary surfaces both layers through `--transactions`, +`--transactions-file`, `--commit-mode`, `--retention`, `--log`, +`--vc`, `--vc-file`, `--branch`, `--branch-from`, `--checkout`, +`--tag`, `--list-branches`, and `--list-tags`. When no flag is passed, +behaviour is byte-identical to the existing CLI — no sidecar is +written and no extra cost is paid. From e55659d6dd8bf058a455d7a05a6f89fdf82e12c3 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 14:32:42 +0000 Subject: [PATCH 16/17] refactor(rust): extract transactions value types into submodule Split the value types (TransitionKind, CommitMode, LogRetentionPolicy, DoubletLink, Transition) and marker prefix constants out of transactions/mod.rs into a new transactions/types.rs submodule so the parent module stays under the 1000-line file-size gate enforced by rust/scripts/check-file-size.rs. mod.rs drops from 1002 to ~797 lines; public API is preserved via re-exports. --- rust/src/transactions/mod.rs | 217 +------------------------------- rust/src/transactions/types.rs | 219 +++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 211 deletions(-) create mode 100644 rust/src/transactions/types.rs diff --git a/rust/src/transactions/mod.rs b/rust/src/transactions/mod.rs index 9c51c66..b9d029f 100644 --- a/rust/src/transactions/mod.rs +++ b/rust/src/transactions/mod.rs @@ -11,6 +11,8 @@ //! Optional — when not opted in, the bare [`NamedTypesDecorator`] //! behaves identically (R8, R9, R17). +mod types; + use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -21,217 +23,10 @@ use anyhow::{anyhow, bail, Context, Result}; use crate::link::Link; use crate::named_types::{NamedTypes, NamedTypesDecorator}; -/// The kind of write operation recorded by a [`Transition`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TransitionKind { - Create, - Update, - Delete, -} - -impl TransitionKind { - fn as_u8(self) -> u8 { - match self { - TransitionKind::Create => 0, - TransitionKind::Update => 1, - TransitionKind::Delete => 2, - } - } - - fn from_u8(value: u8) -> Option { - match value { - 0 => Some(TransitionKind::Create), - 1 => Some(TransitionKind::Update), - 2 => Some(TransitionKind::Delete), - _ => None, - } - } -} - -/// Sync flushes data-store side-effects before `commit` returns. -/// -/// Async durably persists the transitions then applies the data-store -/// side-effects on a background-friendly path (already-applied -/// side-effects are the common case for in-process inner stores). -/// -/// The Rust port runs both modes synchronously on the calling thread -/// for predictability; the distinction is preserved for parity with C# -/// and for future expansion. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CommitMode { - #[default] - Sync, - Async, -} - -/// Retention policy for the transitions log. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum LogRetentionPolicy { - /// Keep every transition forever (default). - #[default] - Infinite, - /// Drop the oldest applied transitions once the live log exceeds - /// `max_transitions`. Never drops un-applied transitions (R7). - Sized { max_transitions: u64 }, - /// Archive the oldest `chunk_size` applied transitions to a - /// rolling file in `archive_directory` once the live log reaches - /// `chunk_size`. - Chunked { - chunk_size: u64, - archive_directory: PathBuf, - }, -} - -impl LogRetentionPolicy { - /// Parses a CLI spec: `infinite`, `sized:`, `chunked::`. - pub fn parse(spec: &str) -> Result { - let trimmed = spec.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("infinite") { - return Ok(Self::Infinite); - } - - let lowered = trimmed.to_ascii_lowercase(); - if lowered.starts_with("sized:") { - let rest = &trimmed["sized:".len()..]; - let max: u64 = rest - .parse() - .map_err(|_| anyhow!("invalid sized retention spec '{spec}'"))?; - return Ok(Self::Sized { - max_transitions: max, - }); - } - if lowered.starts_with("chunked:") { - let rest = &trimmed["chunked:".len()..]; - let (size_text, dir) = rest - .split_once(':') - .ok_or_else(|| anyhow!("invalid chunked retention spec '{spec}'"))?; - let chunk_size: u64 = size_text - .parse() - .map_err(|_| anyhow!("invalid chunked size in '{spec}'"))?; - if chunk_size == 0 { - bail!("invalid chunked size in '{spec}'"); - } - if dir.is_empty() { - bail!("invalid chunked retention spec '{spec}'"); - } - return Ok(Self::Chunked { - chunk_size, - archive_directory: PathBuf::from(dir), - }); - } - bail!("unknown retention spec '{spec}'"); - } -} - -/// A single doublet link state captured by a transition (mirror of the -/// C# `Platform.Data.Doublets.Link`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] -pub struct DoubletLink { - pub index: u32, - pub source: u32, - pub target: u32, -} - -impl DoubletLink { - pub const fn new(index: u32, source: u32, target: u32) -> Self { - Self { - index, - source, - target, - } - } - - pub const fn empty() -> Self { - Self::new(0, 0, 0) - } - - pub fn from_link(link: &Link) -> Self { - Self::new(link.index, link.source, link.target) - } -} - -/// Reversible write captured by the transactions layer. Holds both -/// `before` and `after` link states so the operation can be undone or -/// replayed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Transition { - pub transaction_id: u128, - pub sequence: i64, - pub timestamp_ms: i64, - pub kind: TransitionKind, - pub before: DoubletLink, - pub after: DoubletLink, -} - -impl Transition { - pub(crate) const SCHEMA_VERSION: &'static str = "v1"; - - /// Encodes the transition as a single line stored as the *name* - /// of one link in the log doublets store. - pub fn serialize(&self) -> String { - format!( - "{schema}|{tx:032x}|{seq}|{ms}|{kind}|{bi},{bs},{bt}|{ai},{as_},{at}", - schema = Self::SCHEMA_VERSION, - tx = self.transaction_id, - seq = self.sequence, - ms = self.timestamp_ms, - kind = self.kind.as_u8(), - bi = self.before.index, - bs = self.before.source, - bt = self.before.target, - ai = self.after.index, - as_ = self.after.source, - at = self.after.target, - ) - } - - /// Parses a serialized transition. - pub fn try_parse(text: &str) -> Option { - if text.is_empty() { - return None; - } - let parts: Vec<&str> = text.split('|').collect(); - if parts.len() < 7 { - return None; - } - if parts[0] != Self::SCHEMA_VERSION { - return None; - } - let tx = u128::from_str_radix(parts[1], 16).ok()?; - let seq: i64 = parts[2].parse().ok()?; - let ms: i64 = parts[3].parse().ok()?; - let kind_value: u8 = parts[4].parse().ok()?; - let kind = TransitionKind::from_u8(kind_value)?; - let before = parse_doublet(parts[5])?; - let after = parse_doublet(parts[6])?; - Some(Self { - transaction_id: tx, - sequence: seq, - timestamp_ms: ms, - kind, - before, - after, - }) - } -} - -fn parse_doublet(text: &str) -> Option { - let parts: Vec<&str> = text.split(',').collect(); - if parts.len() != 3 { - return None; - } - Some(DoubletLink { - index: parts[0].parse().ok()?, - source: parts[1].parse().ok()?, - target: parts[2].parse().ok()?, - }) -} - -/// Sidecar-store name prefixes used by the recovery protocol. -pub(crate) const COMMIT_MARKER_PREFIX: &str = "__transactions:commit:"; -pub(crate) const ROLLBACK_MARKER_PREFIX: &str = "__transactions:rollback:"; -pub(crate) const APPLIED_MARKER_PREFIX: &str = "__transactions:applied:"; -pub(crate) const TRANSITION_NAME_PREFIX: &str = "__transactions:transition:"; +pub use types::{CommitMode, DoubletLink, LogRetentionPolicy, Transition, TransitionKind}; +use types::{ + APPLIED_MARKER_PREFIX, COMMIT_MARKER_PREFIX, ROLLBACK_MARKER_PREFIX, TRANSITION_NAME_PREFIX, +}; /// Pending state of a transaction (used by the explicit transaction /// handle and by per-write auto-transactions). diff --git a/rust/src/transactions/types.rs b/rust/src/transactions/types.rs new file mode 100644 index 0000000..8fced7e --- /dev/null +++ b/rust/src/transactions/types.rs @@ -0,0 +1,219 @@ +//! Value types and serialization helpers for the transactions layer. + +use std::path::PathBuf; + +use anyhow::{anyhow, bail, Result}; + +use crate::link::Link; + +/// The kind of write operation recorded by a [`Transition`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TransitionKind { + Create, + Update, + Delete, +} + +impl TransitionKind { + pub(crate) fn as_u8(self) -> u8 { + match self { + TransitionKind::Create => 0, + TransitionKind::Update => 1, + TransitionKind::Delete => 2, + } + } + + pub(crate) fn from_u8(value: u8) -> Option { + match value { + 0 => Some(TransitionKind::Create), + 1 => Some(TransitionKind::Update), + 2 => Some(TransitionKind::Delete), + _ => None, + } + } +} + +/// Sync flushes data-store side-effects before `commit` returns. +/// +/// Async durably persists the transitions then applies the data-store +/// side-effects on a background-friendly path (already-applied +/// side-effects are the common case for in-process inner stores). +/// +/// The Rust port runs both modes synchronously on the calling thread +/// for predictability; the distinction is preserved for parity with C# +/// and for future expansion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CommitMode { + #[default] + Sync, + Async, +} + +/// Retention policy for the transitions log. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum LogRetentionPolicy { + /// Keep every transition forever (default). + #[default] + Infinite, + /// Drop the oldest applied transitions once the live log exceeds + /// `max_transitions`. Never drops un-applied transitions (R7). + Sized { max_transitions: u64 }, + /// Archive the oldest `chunk_size` applied transitions to a + /// rolling file in `archive_directory` once the live log reaches + /// `chunk_size`. + Chunked { + chunk_size: u64, + archive_directory: PathBuf, + }, +} + +impl LogRetentionPolicy { + /// Parses a CLI spec: `infinite`, `sized:`, `chunked::`. + pub fn parse(spec: &str) -> Result { + let trimmed = spec.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("infinite") { + return Ok(Self::Infinite); + } + + let lowered = trimmed.to_ascii_lowercase(); + if lowered.starts_with("sized:") { + let rest = &trimmed["sized:".len()..]; + let max: u64 = rest + .parse() + .map_err(|_| anyhow!("invalid sized retention spec '{spec}'"))?; + return Ok(Self::Sized { + max_transitions: max, + }); + } + if lowered.starts_with("chunked:") { + let rest = &trimmed["chunked:".len()..]; + let (size_text, dir) = rest + .split_once(':') + .ok_or_else(|| anyhow!("invalid chunked retention spec '{spec}'"))?; + let chunk_size: u64 = size_text + .parse() + .map_err(|_| anyhow!("invalid chunked size in '{spec}'"))?; + if chunk_size == 0 { + bail!("invalid chunked size in '{spec}'"); + } + if dir.is_empty() { + bail!("invalid chunked retention spec '{spec}'"); + } + return Ok(Self::Chunked { + chunk_size, + archive_directory: PathBuf::from(dir), + }); + } + bail!("unknown retention spec '{spec}'"); + } +} + +/// A single doublet link state captured by a transition (mirror of the +/// C# `Platform.Data.Doublets.Link`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] +pub struct DoubletLink { + pub index: u32, + pub source: u32, + pub target: u32, +} + +impl DoubletLink { + pub const fn new(index: u32, source: u32, target: u32) -> Self { + Self { + index, + source, + target, + } + } + + pub const fn empty() -> Self { + Self::new(0, 0, 0) + } + + pub fn from_link(link: &Link) -> Self { + Self::new(link.index, link.source, link.target) + } +} + +/// Reversible write captured by the transactions layer. Holds both +/// `before` and `after` link states so the operation can be undone or +/// replayed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Transition { + pub transaction_id: u128, + pub sequence: i64, + pub timestamp_ms: i64, + pub kind: TransitionKind, + pub before: DoubletLink, + pub after: DoubletLink, +} + +impl Transition { + pub(crate) const SCHEMA_VERSION: &'static str = "v1"; + + /// Encodes the transition as a single line stored as the *name* + /// of one link in the log doublets store. + pub fn serialize(&self) -> String { + format!( + "{schema}|{tx:032x}|{seq}|{ms}|{kind}|{bi},{bs},{bt}|{ai},{as_},{at}", + schema = Self::SCHEMA_VERSION, + tx = self.transaction_id, + seq = self.sequence, + ms = self.timestamp_ms, + kind = self.kind.as_u8(), + bi = self.before.index, + bs = self.before.source, + bt = self.before.target, + ai = self.after.index, + as_ = self.after.source, + at = self.after.target, + ) + } + + /// Parses a serialized transition. + pub fn try_parse(text: &str) -> Option { + if text.is_empty() { + return None; + } + let parts: Vec<&str> = text.split('|').collect(); + if parts.len() < 7 { + return None; + } + if parts[0] != Self::SCHEMA_VERSION { + return None; + } + let tx = u128::from_str_radix(parts[1], 16).ok()?; + let seq: i64 = parts[2].parse().ok()?; + let ms: i64 = parts[3].parse().ok()?; + let kind_value: u8 = parts[4].parse().ok()?; + let kind = TransitionKind::from_u8(kind_value)?; + let before = parse_doublet(parts[5])?; + let after = parse_doublet(parts[6])?; + Some(Self { + transaction_id: tx, + sequence: seq, + timestamp_ms: ms, + kind, + before, + after, + }) + } +} + +fn parse_doublet(text: &str) -> Option { + let parts: Vec<&str> = text.split(',').collect(); + if parts.len() != 3 { + return None; + } + Some(DoubletLink { + index: parts[0].parse().ok()?, + source: parts[1].parse().ok()?, + target: parts[2].parse().ok()?, + }) +} + +/// Sidecar-store name prefixes used by the recovery protocol. +pub(crate) const COMMIT_MARKER_PREFIX: &str = "__transactions:commit:"; +pub(crate) const ROLLBACK_MARKER_PREFIX: &str = "__transactions:rollback:"; +pub(crate) const APPLIED_MARKER_PREFIX: &str = "__transactions:applied:"; +pub(crate) const TRANSITION_NAME_PREFIX: &str = "__transactions:transition:"; From 08a5ba76b3acfe346019e8422531bb7e51cbccc4 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 20 May 2026 15:55:01 +0000 Subject: [PATCH 17/17] fix(issue-94): verify full-stack transaction ACID behavior --- .../NamedTypesDecorator.cs | 52 +- .../TransactionsDecorator.cs | 113 +++-- .../VersionControlDecorator.cs | 155 +++++- .../TransactionsDecoratorTests.cs | 9 +- .../VersionControlDecoratorTests.cs | 153 +++++- docs/ARCHITECTURE.md | 16 +- docs/HOW-IT-WORKS.md | 7 +- docs/REQUIREMENTS.md | 4 +- docs/case-studies/issue-94/README.md | 454 +++++------------- rust/src/version_control/mod.rs | 96 +++- rust/tests/version_control_decorator_tests.rs | 128 ++++- 11 files changed, 757 insertions(+), 430 deletions(-) diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs index 8588c93..663d864 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Numerics; +using System.Reflection; using Platform.Delegates; using Platform.Memory; using Platform.Data; @@ -12,7 +13,7 @@ namespace Foundation.Data.Doublets.Cli { - public class NamedTypesDecorator : LinksDecoratorBase, INamedTypesLinks, IPinnedTypes + public class NamedTypesDecorator : LinksDecoratorBase, INamedTypesLinks, IPinnedTypes, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -24,6 +25,8 @@ public class NamedTypesDecorator : LinksDecoratorBase PinnedTypesDecorator; public readonly NamedLinks NamedLinks; public readonly string NamedLinksDatabaseFileName; + private readonly ILinks _namedLinksFacade; + private bool _disposed; public static ILinks MakeLinks(string databaseFilename) { @@ -53,6 +56,7 @@ public NamedTypesDecorator(PinnedTypesDecorator pinnedTypesDecorat var namesMemory = new FileMappedResizableDirectMemory(namesDatabaseFilename, UnitedMemoryLinks.DefaultLinksSizeStep); var namesLinks = new UnitedMemoryLinks(namesMemory, UnitedMemoryLinks.DefaultLinksSizeStep, namesConstants, IndexTreeType.Default); var decoratedNamesLinks = namesLinks.DecorateWithAutomaticUniquenessAndUsagesResolution(); + _namedLinksFacade = decoratedNamesLinks; NamedLinks = new UnicodeStringStorage(decoratedNamesLinks).NamedLinks; NamedLinksDatabaseFileName = namesDatabaseFilename; } @@ -62,6 +66,52 @@ public NamedTypesDecorator(string databaseFilename, bool tracingEnabled = false) { } + public void Dispose() + { + if (_disposed) return; + _disposed = true; + DisposeLinksFacade(_namedLinksFacade); + DisposeLinksFacade(PinnedTypesDecorator); + } + + private static void DisposeLinksFacade(object? facade) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + DisposeLinksFacade(facade, visited); + } + + private static void DisposeLinksFacade(object? facade, HashSet visited) + { + if (facade is null || !visited.Add(facade)) + { + return; + } + + foreach (var inner in EnumerateInnerLinks(facade)) + { + DisposeLinksFacade(inner, visited); + } + + if (facade is IDisposable disposable) + { + disposable.Dispose(); + } + } + + private static IEnumerable EnumerateInnerLinks(object facade) + { + for (var type = facade.GetType(); type is not null; type = type.BaseType) + { + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (typeof(ILinks).IsAssignableFrom(field.FieldType)) + { + yield return field.GetValue(facade); + } + } + } + } + public IEnumerator GetEnumerator() { return PinnedTypesDecorator.GetEnumerator(); diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs index fa5908e..8f2b46c 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -321,22 +321,27 @@ private uint RunWrite( } var @continue = _inner.Constants.Continue; - DoubletLink? firstBefore = null; - DoubletLink? lastAfter = null; - var observedAny = false; + var observed = new Dictionary(); + var observedOrder = new List(); WriteHandler wrapped = (before, after) => { - // The underlying store can fire the handler multiple times for one - // logical write (e.g. Delete first clears the link, then removes it). - // Collapse them into a single transition with the original before - // state and the final after state. - if (!observedAny) + var beforeLink = before is null ? default(DoubletLink?) : new DoubletLink(before); + var afterLink = after is null ? default(DoubletLink?) : new DoubletLink(after); + var key = beforeLink?.Index ?? afterLink?.Index ?? 0; + if (key != 0) { - firstBefore = before is null ? default(DoubletLink?) : new DoubletLink(before); - observedAny = true; + if (!observed.TryGetValue(key, out var state)) + { + observedOrder.Add(key); + state = (beforeLink, afterLink); + } + else + { + state = (state.Before ?? beforeLink, afterLink); + } + observed[key] = state; } - lastAfter = after is null ? default(DoubletLink?) : new DoubletLink(after); return userHandler is null ? @continue : userHandler(before, after); }; @@ -359,9 +364,12 @@ private uint RunWrite( throw; } - if (observedAny) + foreach (var key in observedOrder) { - RecordTransition(transaction, kind, firstBefore ?? default, lastAfter ?? default); + var state = observed[key]; + var before = state.Before ?? default; + var after = state.After ?? default; + RecordTransition(transaction, kind, before, after); } if (ownsTransaction) @@ -595,31 +603,13 @@ private void TryRevertTransition(Transition transition) { try { - switch (transition.Kind) + if (transition.Before.Index == 0) { - case TransitionKind.Create: - if (transition.After.Index != 0 && _inner.Exists(transition.After.Index)) - { - _inner.Delete(new DoubletLink(transition.After.Index, _inner.Constants.Any, _inner.Constants.Any), null); - } - break; - case TransitionKind.Update: - if (transition.Before.Index != 0 && _inner.Exists(transition.Before.Index)) - { - _inner.Update( - new DoubletLink(transition.Before.Index, _inner.Constants.Any, _inner.Constants.Any), - new DoubletLink(transition.Before.Index, transition.Before.Source, transition.Before.Target), - null); - } - break; - case TransitionKind.Delete: - if (transition.Before.Index != 0 && !_inner.Exists(transition.Before.Index)) - { - // Doublets storage reuses freed slots in order, so creating - // with the original source/target restores the link in place. - _inner.CreateAndUpdate(transition.Before.Source, transition.Before.Target); - } - break; + DeleteIfExists(transition.After.Index); + } + else + { + RestoreLink(transition.Before); } } catch (Exception ex) @@ -676,29 +666,13 @@ private void TryApplyTransition(Transition transition, bool recordApplied) { try { - switch (transition.Kind) + if (transition.After.Index == 0) { - case TransitionKind.Create: - if (transition.After.Index != 0 && !_inner.Exists(transition.After.Index)) - { - _inner.CreateAndUpdate(transition.After.Source, transition.After.Target); - } - break; - case TransitionKind.Update: - if (transition.After.Index != 0 && _inner.Exists(transition.After.Index)) - { - _inner.Update( - new DoubletLink(transition.After.Index, _inner.Constants.Any, _inner.Constants.Any), - new DoubletLink(transition.After.Index, transition.After.Source, transition.After.Target), - null); - } - break; - case TransitionKind.Delete: - if (transition.Before.Index != 0 && _inner.Exists(transition.Before.Index)) - { - _inner.Delete(new DoubletLink(transition.Before.Index, _inner.Constants.Any, _inner.Constants.Any), null); - } - break; + DeleteIfExists(transition.Before.Index); + } + else + { + RestoreLink(transition.After); } if (recordApplied) @@ -721,6 +695,27 @@ private void MarkApplied(Transition transition) } } + private void RestoreLink(DoubletLink link) + { + if (link.Index == 0) return; + if (!_inner.Exists(link.Index)) + { + _inner.EnsureCreated(link.Index); + } + _inner.Update( + new DoubletLink(link.Index, _inner.Constants.Any, _inner.Constants.Any), + new DoubletLink(link.Index, link.Source, link.Target), + null); + } + + private void DeleteIfExists(uint index) + { + if (index != 0 && _inner.Exists(index)) + { + _inner.Delete(new DoubletLink(index, _inner.Constants.Any, _inner.Constants.Any), null); + } + } + internal void WriteTransitionToLog(Transition transition) { var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs index f6972ec..bf9bba9 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs @@ -20,6 +20,8 @@ public interface IVersionControlLinks : INamedTypesLinks { string CurrentBranch { get; } long CurrentSequence { get; } + ITransaction BeginTransaction(); + Task BeginTransactionAsync(CancellationToken cancellationToken = default); IReadOnlyList ListBranches(); IReadOnlyDictionary ListTags(); void Branch(string name, long? from = null); @@ -59,6 +61,7 @@ public sealed class VersionControlDecorator : LinksDecoratorBase, IVersion private uint _appliedLink; private string _currentBranch = DefaultBranchName; private long _currentApplied; + private VersionControlTransaction? _activeTransaction; private readonly bool _trace; public VersionControlDecorator( @@ -92,6 +95,29 @@ public bool TryGetTag(string name, out long sequence) lock (_lock) return _tags.TryGetValue(name, out sequence); } + public ITransaction BeginTransaction() + { + lock (_lock) + { + if (_activeTransaction is not null) + { + throw new InvalidOperationException("Nested version-control transactions are not supported."); + } + + var beforeSequence = _transactions.LastLoggedSequence; + var branchName = _currentBranch; + var inner = _transactions.BeginTransaction(); + _activeTransaction = new VersionControlTransaction(this, inner, branchName, beforeSequence); + return _activeTransaction; + } + } + + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(BeginTransaction()); + } + // -- Write overrides (attribute new transitions to the current branch) -- public override uint Create(IList? substitution, WriteHandler? handler) @@ -115,27 +141,37 @@ private uint RunVcWrite(Func innerWrite) { var beforeSeq = _transactions.LastLoggedSequence; var result = innerWrite(); - var afterSeq = _transactions.LastLoggedSequence; - if (afterSeq > beforeSeq) + if (_activeTransaction is null) { - for (var s = beforeSeq + 1; s <= afterSeq; s++) - { - _transitionBranches[s] = _currentBranch; - WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={_currentBranch}"); - } - if (_branches.TryGetValue(_currentBranch, out var info)) - { - var updated = info with { Head = afterSeq }; - _branches[_currentBranch] = updated; - UpdateBranchLinkLocked(updated); - } - _currentApplied = afterSeq; - SetAppliedLocked(afterSeq); + AttributeNewTransitionsLocked(beforeSeq, _currentBranch); } return result; } } + private void AttributeNewTransitionsLocked(long beforeSeq, string branchName) + { + var afterSeq = _transactions.LastLoggedSequence; + if (afterSeq <= beforeSeq) return; + + for (var s = beforeSeq + 1; s <= afterSeq; s++) + { + _transitionBranches[s] = branchName; + WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={branchName}"); + } + if (_branches.TryGetValue(branchName, out var info)) + { + var updated = info with { Head = afterSeq }; + _branches[branchName] = updated; + UpdateBranchLinkLocked(updated); + } + if (string.Equals(_currentBranch, branchName, StringComparison.Ordinal)) + { + _currentApplied = afterSeq; + SetAppliedLocked(afterSeq); + } + } + // -- Branching --------------------------------------------------------- public void Branch(string name, long? from = null) @@ -146,6 +182,7 @@ public void Branch(string name, long? from = null) } lock (_lock) { + EnsureNoOpenTransactionLocked(nameof(Branch)); if (_branches.ContainsKey(name)) { throw new InvalidOperationException($"Branch '{name}' already exists."); @@ -173,6 +210,7 @@ public void SwitchBranch(string name) { lock (_lock) { + EnsureNoOpenTransactionLocked(nameof(SwitchBranch)); if (!_branches.TryGetValue(name, out var target)) { throw new InvalidOperationException($"Unknown branch '{name}'."); @@ -187,6 +225,7 @@ public void Checkout(long sequence) { lock (_lock) { + EnsureNoOpenTransactionLocked(nameof(Checkout)); if (sequence < 0) { throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must be non-negative."); @@ -209,6 +248,7 @@ public void Tag(string name, long? sequence = null) } lock (_lock) { + EnsureNoOpenTransactionLocked(nameof(Tag)); var seq = sequence ?? _currentApplied; if (seq < 0) { @@ -258,6 +298,45 @@ private void ApplyDiffToLocked(List targetPath, string newBranch) SetAppliedLocked(_currentApplied); } + private void EnsureNoOpenTransactionLocked(string operation) + { + if (_activeTransaction is not null) + { + throw new InvalidOperationException($"{operation} is not allowed while a version-control transaction is open."); + } + } + + private void CommitVersionTransaction(VersionControlTransaction transaction) + { + lock (_lock) + { + transaction.Inner.Commit(); + if (ReferenceEquals(_activeTransaction, transaction)) + { + _activeTransaction = null; + AttributeNewTransitionsLocked(transaction.BeforeSequence, transaction.BranchName); + } + } + } + + private void RollbackVersionTransaction(VersionControlTransaction transaction) + { + lock (_lock) + { + try + { + transaction.Inner.Rollback(); + } + finally + { + if (ReferenceEquals(_activeTransaction, transaction)) + { + _activeTransaction = null; + } + } + } + } + private List BuildBranchSeqsLocked(string branchName) { return BuildBranchSeqsLocked(branchName, new HashSet(StringComparer.Ordinal)); @@ -503,4 +582,50 @@ private void Trace(string message) { if (_trace) Console.WriteLine($"[VersionControl] {message}"); } + + private sealed class VersionControlTransaction : ITransaction + { + private readonly VersionControlDecorator _owner; + + internal VersionControlTransaction( + VersionControlDecorator owner, + ITransaction inner, + string branchName, + long beforeSequence) + { + _owner = owner; + Inner = inner; + BranchName = branchName; + BeforeSequence = beforeSequence; + } + + internal ITransaction Inner { get; } + internal string BranchName { get; } + internal long BeforeSequence { get; } + + public Guid Id => Inner.Id; + public DateTimeOffset StartedAt => Inner.StartedAt; + public bool IsCommitted => Inner.IsCommitted; + public bool IsRolledBack => Inner.IsRolledBack; + public IReadOnlyList Transitions => Inner.Transitions; + + public void Commit() => _owner.CommitVersionTransaction(this); + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _owner.CommitVersionTransaction(this); + return Task.CompletedTask; + } + + public void Rollback() => _owner.RollbackVersionTransaction(this); + + public void Dispose() + { + if (!Inner.IsCommitted && !Inner.IsRolledBack) + { + _owner.RollbackVersionTransaction(this); + } + } + } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs index 70bb9d6..ed05558 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs @@ -232,8 +232,9 @@ public void NoBehaviourChangeWhenNotOptedIn() } finally { + dataLinks?.Dispose(); Cleanup(dataFile); - if (dataLinks is not null) Cleanup(dataLinks.NamedLinksDatabaseFileName); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); } } @@ -254,10 +255,12 @@ private static void RunWithTransactions(Action.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs index 7d1a617..7d4ed34 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs @@ -194,6 +194,138 @@ public void DuplicateBranchThrows() }); } + [Fact] + public void FullStackAcidRollbackIsAtomicAndIsolated() + { + RunWithVc((vc, _, _) => + { + var baseline = Snapshot(vc); + var initialSequence = vc.CurrentSequence; + + using (var transaction = vc.BeginTransaction()) + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Update( + new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), + new DoubletLink(a, b, b), + null); + + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + Assert.Throws(() => vc.BeginTransaction()); + Assert.Throws(() => vc.Branch("blocked")); + + transaction.Rollback(); + } + + Assert.Equal(initialSequence, vc.CurrentSequence); + Assert.Equal(initialSequence, vc.ListBranches().Single(b => b.Name == VersionControlDecorator.DefaultBranchName).Head); + Assert.Equal(baseline, Snapshot(vc)); + }); + } + + [Fact] + public void FullStackAcidCommitIsConsistentAndDurableAcrossReopen() + { + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + var vcFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + NamedTypesDecorator? vcLinks = null; + TransactionsDecorator? tx = null; + NamedTypesDecorator? reopenedDataLinks = null; + NamedTypesDecorator? reopenedLogLinks = null; + NamedTypesDecorator? reopenedVcLinks = null; + TransactionsDecorator? reopenedTx = null; + + try + { + uint a; + uint b; + long committedSequence; + + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + vcLinks = new NamedTypesDecorator(vcFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + var vc = new VersionControlDecorator(tx, vcLinks); + + using (var transaction = vc.BeginTransaction()) + { + a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Update( + new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), + new DoubletLink(a, b, b), + null); + transaction.Commit(); + } + + committedSequence = vc.CurrentSequence; + Assert.True(committedSequence >= 5); + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + Assert.Equal(committedSequence, vc.ListBranches().Single(branch => branch.Name == VersionControlDecorator.DefaultBranchName).Head); + + vc.Tag("acid-commit"); + vc.Branch("audit"); + vc.SwitchBranch("audit"); + vc.Delete(new DoubletLink(b, vc.Constants.Any, vc.Constants.Any), null); + Assert.False(vc.Exists(b)); + + vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + var restored = new DoubletLink(vc.GetLink(a)); + Assert.Equal(b, restored.Source); + Assert.Equal(b, restored.Target); + + tx.Shutdown(); + tx = null; + dataLinks.Dispose(); + dataLinks = null; + logLinks.Dispose(); + logLinks = null; + vcLinks.Dispose(); + vcLinks = null; + + reopenedDataLinks = new NamedTypesDecorator(dataFile); + reopenedLogLinks = new NamedTypesDecorator(logFile); + reopenedVcLinks = new NamedTypesDecorator(vcFile); + reopenedTx = new TransactionsDecorator(reopenedDataLinks, reopenedLogLinks); + var reopened = new VersionControlDecorator(reopenedTx, reopenedVcLinks); + + Assert.True(reopened.TryGetTag("acid-commit", out var tagSequence)); + Assert.Equal(committedSequence, tagSequence); + Assert.Contains(reopened.ListBranches(), branch => branch.Name == "audit"); + Assert.Equal(VersionControlDecorator.DefaultBranchName, reopened.CurrentBranch); + Assert.True(reopened.Exists(a)); + Assert.True(reopened.Exists(b)); + restored = new DoubletLink(reopened.GetLink(a)); + Assert.Equal(b, restored.Source); + Assert.Equal(b, restored.Target); + Assert.Equal(reopenedTx.LastLoggedSequence, reopenedTx.AppliedSequence); + } + finally + { + tx?.Shutdown(); + reopenedTx?.Shutdown(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + vcLinks?.Dispose(); + reopenedDataLinks?.Dispose(); + reopenedLogLinks?.Dispose(); + reopenedVcLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(vcFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); + } + } + private static void RunWithVc(Action> action) { var dataFile = Path.GetTempFileName(); @@ -215,12 +347,15 @@ private static void RunWithVc(Action.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); } } @@ -228,5 +363,17 @@ private static void Cleanup(string path) { if (File.Exists(path)) File.Delete(path); } + + private static IReadOnlyList Snapshot(ILinks links) + { + var any = links.Constants.Any; + var query = new DoubletLink(any, any, any); + return links.All(query) + .Select(link => new DoubletLink(link)) + .OrderBy(link => link.Index) + .ThenBy(link => link.Source) + .ThenBy(link => link.Target) + .ToArray(); + } } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ab060e..eb2f65e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -151,9 +151,13 @@ Composition: `LinkStorage → NamedTypesDecorator → TransactionsDecorator`. Public surface: -- `Create / Update / Delete / CreateAndUpdate` — recorded automatically. -- `BeginTransaction()` — explicit batch with `Commit()` and `Rollback()`; - drop without commit rolls back automatically. +- `Create / Update / Delete / CreateAndUpdate` — recorded automatically; + logical writes that affect multiple links record one transition per affected + link so rollback and checkout restore the complete graph. +- `BeginTransaction()` / `begin_transaction()` — explicit batches with + commit and rollback APIs. C# returns a disposable transaction handle; + Rust keeps the active transaction on the decorator and commits or rolls + it back through that decorator. - `Log()` — read the recorded transitions. - Three retention policies: `infinite`, `sized:` (drop oldest applied), and `chunked::` (archive oldest applied to rolling files). @@ -161,6 +165,9 @@ Public surface: returning) and `async` (durably persists the log first). - Crash recovery: on open, every committed-but-not-applied transition is replayed against the underlying store. +- Deterministic disposal: file-backed `NamedTypesDecorator` instances close + the decorated data and names stores so tests and callers can reopen the same + sidecar files in-process. When no transaction flag is passed at the CLI and the decorator is not instantiated through the library API, the existing `NamedTypesDecorator` @@ -180,6 +187,9 @@ over the recorded transitions log: sequence number. - **Time-travel checkout** — `Checkout(seq)` rewinds (or replays) the live store to an arbitrary sequence number. +- **Version-control transactions** — `BeginTransaction()` delegates to the + inner transactions layer and defers branch attribution until commit; rollback + leaves branch heads and transition-to-branch metadata unchanged. Composition: `LinkStorage → NamedTypesDecorator → TransactionsDecorator → VersionControlDecorator`. diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 57ee0fc..cddc69f 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -191,9 +191,10 @@ Inspect the log with `--log`: 2 2026-05-20T14:14:03 Create ca32... (0,0,0) -> (2,2,2) ``` -The library exposes `BeginTransaction()` returning a handle with -`Commit()` and `Rollback()`. A handle dropped without commit rolls back -automatically. +The C# library exposes `BeginTransaction()` returning a handle with +`Commit()` and `Rollback()`; disposing an uncommitted handle rolls back. +The Rust library exposes the same explicit flow as +`begin_transaction()`, `commit()`, and `rollback()` on the decorator. Retention policies (`--retention`): diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 7a7b5fc..7c9e77f 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -43,8 +43,8 @@ the core CLI behavior and is also used by the WebAssembly browser workbench. | Import a database from LiNo. | #25, #43 | `--in`, `--import`, and `--lino-input` read a LiNo file before query execution. | | Format a link structure. | #19, #48 | `--structure` recursively formats the left branch with indexes preserved. | | Store persistent transformations. | #3, #55 | C# supports `--always`, `--once`, `--never`, `--triggers`, `--triggers-file`, and `--embed-triggers`. | -| Optional transactions layer. | #94 | C# and Rust expose `--transactions`, `--transactions-file`, `--commit-mode`, `--retention`, and `--log`. Each Create/Update/Delete is recorded as a reversible transition in a doublets-store sidecar; explicit `BeginTransaction()` / `Commit()` / `Rollback()` APIs are available in both libraries. Three retention policies are supported: `infinite`, `sized:`, and `chunked::`. Crash recovery replays committed transitions on the next open. When no flag is passed, behaviour is identical to the bare CLI (no sidecar, no cost). | -| Optional version-control layer. | #94 | C# and Rust expose `--vc`, `--vc-file`, `--branch`, `--branch-from`, `--checkout`, `--tag`, `--list-branches`, and `--list-tags`. The version-control decorator sits above the transactions decorator and adds branching (named DAG of branches), tagging (named pointers to sequence numbers), and time-travel checkout (rewind/replay transitions). When no flag is passed, no version-control sidecar is created. | +| Optional transactions layer. | #94 | C# and Rust expose `--transactions`, `--transactions-file`, `--commit-mode`, `--retention`, and `--log`. Each Create/Update/Delete is recorded as one or more reversible transitions in a doublets-store sidecar; explicit `BeginTransaction()` / `Commit()` / `Rollback()` APIs are available in both libraries. Three retention policies are supported: `infinite`, `sized:`, and `chunked::`. Crash recovery replays committed transitions on the next open. When no flag is passed, behaviour is identical to the bare CLI (no sidecar, no cost). | +| Optional version-control layer. | #94 | C# and Rust expose `--vc`, `--vc-file`, `--branch`, `--branch-from`, `--checkout`, `--tag`, `--list-branches`, and `--list-tags`. The version-control decorator sits above the transactions decorator and adds branching (named DAG of branches), tagging (named pointers to sequence numbers), and time-travel checkout (rewind/replay transitions). Version-control transactions defer branch attribution until commit, so rollback does not leave branch metadata for discarded transitions. Full-stack ACID tests cover rollback, branch isolation, commit consistency, and reopen durability across both layers. When no flag is passed, no version-control sidecar is created. | | Separate code by implementation language. | #63, #64, #77, #79 | C# code and release helpers live under `csharp/`; Rust code, release helpers, and the WebAssembly wrapper crate live under `rust/`; the browser app and JavaScript lockfile live under `js/`. | | Provide Rust parity for core behavior. | #63, #67, #68 | Rust mirrors query processing, names, import/export, structure formatting, and Unicode sequence support. | | Run in a browser. | #12, #52, #69, #70 | The Rust query processor is wrapped with `wasm-bindgen` and surfaced through a React/Vite workbench. | diff --git a/docs/case-studies/issue-94/README.md b/docs/case-studies/issue-94/README.md index 566dd24..bdf7e1e 100644 --- a/docs/case-studies/issue-94/README.md +++ b/docs/case-studies/issue-94/README.md @@ -5,16 +5,12 @@ Issue: Prepared PR: [#95](https://github.com/link-foundation/link-cli/pull/95) > Scope of this case study: this folder captures evidence, restated -> requirements, prior-art analysis, and a multi-phase implementation plan -> for shipping an *optional* transactions decorator and an *optional* -> version-control decorator in both the C# and Rust implementations of -> `link-cli` (CLI + library). The case study is the deliverable for the -> first half of the issue. The actual code implementation is split into -> follow-up engineering work tracked from this same PR, because the issue -> body explicitly asks to first *"collect data related about the issue to -> this repository, make sure we compile that data to -> `./docs/case-studies/issue-{id}` folder, and use it to do deep case study -> analysis"* before the code lands. +> requirements, prior-art analysis, the implemented design, and verification +> evidence for the optional transactions decorator and optional version-control +> decorator shipped by PR #95 in both the C# and Rust implementations of +> `link-cli` (CLI + library). The issue first asked for deep case-study data +> under `./docs/case-studies/issue-{id}`; this document now records both that +> analysis and the implementation that followed it. ## 1. Issue summary @@ -31,8 +27,7 @@ decorator already wrap): log/transitions). 2. **Version-control layer** — sit on top of the transactions layer to provide *time travel* to any point covered by the log, plus *branching* - that creates a new transitions file starting from a point in time of an - existing branch. + from a point in time of an existing branch. The work must be delivered in both **C# and Rust**, both as **CLI flags** and as **public library APIs**, and must compose with the existing @@ -46,15 +41,15 @@ as a starting reference, while noting that it is *not finished* and we ## 2. Restated requirements -Broken down into discrete, individually-testable requirements so that the -implementation pull request can check each one off: +Broken down into discrete, individually-testable requirements so PR #95 can +check each one off: ### Transactions (R1–R10) | ID | Requirement | |-----|-------------| | R1 | Each link operation (Create, Update, Delete) is recorded as a *reversible transition* with enough information to recompute both the *before* and *after* state. | -| R2 | A `BeginTransaction()` API returns a `Transaction` handle that supports `Commit()`, `Rollback()`, and `Dispose()` (auto-rollback if dropped without commit). | +| R2 | A transaction API opens an explicit write batch and supports `Commit()` / `Rollback()` semantics; C# exposes this as a disposable transaction handle, while Rust exposes `begin_transaction()`, `commit()`, and `rollback()` on the decorator. | | R3 | A rolled-back transaction reverts every recorded transition in reverse order (delete-of-create → delete, create-of-delete → recreate-with-same-id, update → restore previous values), restoring identical state. | | R4 | The transactions layer is implemented as a *decorator* over the existing `ILinks` / `LinkStorage` surface, with the same public methods, so it composes with `NamedTypesDecorator`, `PinnedTypesDecorator`, and `PersistentTransformationDecorator`. | | R5 | The transitions log itself is a *doublets store*, not a bespoke binary file (the log is "also doublets storage"); the layer therefore takes two underlying links sources at construction time — one for data, one for transitions. | @@ -70,10 +65,10 @@ implementation pull request can check each one off: |-----|-------------| | R11 | A `VersionControlDecorator` sits on top of the transactions layer and exposes the same `ILinks` / `LinkStorage` surface plus VC-specific operations. | | R12 | `Checkout(point)` *time-travels* the data store to the state at a given transition (by id, by timestamp, or by a named tag), by replaying or rewinding transitions from the current head. | -| R13 | `Branch(name, from?)` creates a new branch starting from the specified point (or the current head). Each branch is backed by its own transitions file that starts after the parent's branch-point. | +| R13 | `Branch(name, from?)` creates a new branch starting from the specified point (or the current head). Each branch is represented by version-control metadata over the shared transitions timeline, so branch state remains a links database without copying the whole log. | | R14 | `ListBranches()` / `CurrentBranch()` / `SwitchBranch(name)` let the caller enumerate and switch between branches. | | R15 | `Tag(point, name)` and `ListTags()` create human-friendly references to specific points in the history (analogous to git tags). | -| R16 | The version-control layer composes correctly with the transactions layer above it (so write operations during version control time-travel are recorded back into the appropriate branch's log). | +| R16 | The version-control layer composes correctly with the transactions layer below it: normal writes are attributed to the current branch, checkout/switch replay does not create new transitions, and explicit VC transactions attribute branch metadata only after commit. | | R17 | The version-control layer is *optional* — existing CLI invocations and library users that do not opt in see no behavior change. | ### Cross-cutting (R18–R23) @@ -130,12 +125,10 @@ The upstream `UInt64LinksTransactionsLayer` also inherits `LinksDisposableDecoratorBase` — the same base — so the C# layer already has a well-defined place in the existing composition stack. -On the Rust side `link-cli` does not yet have an explicit decorator -trait; storage is centered on `LinkStorage` plus `NamedLinks` / -`PinnedTypes` types. Adding a transactions layer to Rust therefore also -requires *introducing* a small "links decorator" indirection in `rust/src` -so the new layer can be stacked the same way it is in C#. This is called -out as a design choice in §6. +On the Rust side `link-cli` storage is centered on `LinkStorage` plus +`NamedLinks` / `PinnedTypes` types. PR #95 introduces the small wrapper +indirection needed to stack the transactions and version-control layers in +the same order as C#. ## 5. Prior art and online research @@ -212,298 +205,105 @@ rather than re-implemented: - **`Platform.Timestamps.UniqueTimestampFactory`** — already used by the upstream layer to produce monotonic timestamps for transitions. - **`Platform.IO.FileHelpers`** — append-only file helpers already used by - the upstream reference. We will *replace* the binary file with a - doublets store (per R5), but if we ever need a side-channel marker file, - this is the established way. + the upstream reference. PR #95 replaces the binary file with a + doublets store (per R5); if a future side-channel marker file is needed, + this remains the established helper. - **`doublets` crate** (Rust) — already a dependency, provides the same storage primitives in Rust. We can stack a new decorator over it. -## 6. Solution plan - -The plan is split into six self-contained steps. Each step is committable -and reviewable independently and is sized to fit a single follow-up -commit on PR #95. - -### Step S1 — Establish the case study (this commit) - -Create `docs/case-studies/issue-94/` with this README, evidence files, -and references. **Done in this commit.** Satisfies R22. - -### Step S2 — Define the shared `ITransactionsLinks` API surface - -Add the API surface in both languages without an implementation. This -locks the design down and lets the test plan be written before the actual -storage code. - -In C# (`csharp/Foundation.Data.Doublets.Cli.Library/Transactions/`): - -```csharp -public interface ITransactionsLinks : ILinks -{ - ITransaction BeginTransaction(); // R2 - Task BeginTransactionAsync(CancellationToken ct = default); - IReadOnlyList> Log { get; } - LogRetentionPolicy RetentionPolicy { get; } - CommitMode CommitMode { get; set; } // Sync vs. Async (R8) -} - -public interface ITransaction : IDisposable -{ - Guid Id { get; } - DateTimeOffset StartedAt { get; } - bool IsCommitted { get; } - bool IsRolledBack { get; } - void Commit(); // R2 - void Rollback(); // R2 - Task CommitAsync(CancellationToken ct = default); -} - -public readonly record struct Transition( - Guid TransactionId, - long Sequence, - DateTimeOffset Timestamp, - Link Before, - Link After); - -public enum CommitMode { Sync, Async } // R8 - -public abstract record LogRetentionPolicy -{ - public sealed record Infinite() : LogRetentionPolicy; // R6 - public sealed record Chunked(long ChunkSize, string ArchiveDirectory) : LogRetentionPolicy; // R6 - public sealed record Sized(long MaxBytes) : LogRetentionPolicy; // R6 + R7 -} -``` - -In Rust (`rust/src/transactions/mod.rs`): - -```rust -pub trait TransactionsLinks: LinksStorage { - type Transaction: Transaction; - fn begin_transaction(&self) -> Self::Transaction; - fn log(&self) -> &dyn LogReader; - fn commit_mode(&self) -> CommitMode; - fn set_commit_mode(&mut self, mode: CommitMode); -} - -pub trait Transaction: Drop { - fn id(&self) -> u128; - fn started_at(&self) -> SystemTime; - fn is_committed(&self) -> bool; - fn is_rolled_back(&self) -> bool; - fn commit(self) -> Result<()>; - fn rollback(self) -> Result<()>; -} - -pub enum CommitMode { Sync, Async } -pub enum LogRetentionPolicy { - Infinite, - Chunked { chunk_size: u64, archive_dir: PathBuf }, - Sized { max_bytes: u64 }, -} -``` - -The `LinksStorage` trait extracted on the Rust side is the new -indirection mentioned in §4. It is a minimal trait covering `create`, -`update`, `delete`, `each`, `get_link`, and `exists` — exactly the -methods that `LinkStorage` already implements. Existing call sites -re-route through the trait without behavior change. - -Satisfies the *API* parts of R1, R2, R4, R6, R8, R18, R19. - -### Step S3 — Implement transactions on top of a *doublets* log - -In each language, implement the API by storing transitions as links in a -*second* doublets store. The store layout encodes one transition as a -small graph: - -```text -( :transaction-root) -( :sequence-of ) -( :timestamp ) -( :before-source ) -( :before-target ) -( :after-source ) -( :after-target ) -``` - -The keys `:transaction-root`, `:sequence-of`, `:timestamp`, -`:before-source`, `:before-target`, `:after-source`, `:after-target` are -*named points* exactly the way `PersistentTransformationDecorator` -already represents `Type`, `Trigger`, `Once`, `Always`, `Condition`, -`Substitution` in the trigger sidecar (see -`csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs:242-258`). -This means the transitions log is itself queryable through the existing -LiNo query processor — which directly supports the issue's "log itself is -also doublets storage" requirement (R5). - -The C# implementation derives from `LinksDisposableDecoratorBase` -exactly like `UInt64LinksTransactionsLayer` does, and the Rust -implementation implements the new `TransactionsLinks` trait by wrapping -two `LinkStorage` instances (one for data, one for the log). - -Implementation notes: - -- `Commit()` (sync mode, R8) walks the in-memory transaction's transition - list and synchronously writes each transition record into the log - store, then *applies* it to the data store, then flushes both. -- `Commit()` (async mode, R8) writes the transition records into the log - store synchronously, then enqueues the data-store application onto a - background task. The transaction is "committed" as soon as the log is - durable, mirroring SQLite's WAL commit semantics. -- `Rollback()` (R3) iterates the transitions in reverse and inverts each - operation against the *data* store (create → delete by id, delete → - recreate with prior source/target, update → update back). The log - records the rollback as additional transitions tagged with the parent - transaction's id so the history remains complete and reproducible. -- `Dispose()` on `Transaction` calls `Rollback()` if `IsCommitted == - false && IsRolledBack == false` (mirrors the upstream reference). -- The `Sized` retention policy (R7) only drops the oldest *applied* - chunk: a transition is "applied" once its data-store write has - succeeded. In async mode the "applied" set is exactly the prefix the - background applier has caught up to. - -Satisfies R1, R2, R3, R4, R5, R6, R7, R8. - -### Step S4 — Recovery, durability, and async backpressure - -On startup the transactions layer scans the log, finds the last fully -applied transition (the one whose data-store side-effect is observable), -and either: - -- replays remaining log entries forward into the data store (async mode - catch-up), or -- rolls back any log entries that belong to an *un-committed* transaction - (transactions whose final `:commit` marker is missing). - -In async mode the background applier signals backpressure to the writer -when the log gets too far ahead of the data store, by transparently -falling back to sync commits until the queue has caught up. This is the -canonical WAL recovery + checkpoint pattern from the PostgreSQL and -SQLite write-ups cited in §5.2. - -Satisfies R10. - -### Step S5 — Implement the `VersionControlDecorator` - -On top of the transactions layer, add a separate decorator that adds: - -- a `branches` named-points subgraph in the log store - (`:branch `, `:branch-head `, `:branch-parent - `, `:branch-parent-point `); -- a `tags` named-points subgraph (`:tag `, `:tag-point - `); -- a `Checkout(point)` method (R12) that walks the data store back to the - requested point by inverting transitions newer than the point and - re-applying them when checking out a forward point; -- a `Branch(name, from?)` method (R13) that creates a new branch row in - the log and *forks* the underlying log file into a new sidecar - (`..transitions.links`) so further writes on that - branch don't pollute the parent's log; -- a `SwitchBranch(name)` method (R14) that performs a `Checkout(point)` - to the branch's head and points all subsequent writes at the branch's - log; -- a `Tag(point, name)` / `ListTags()` API (R15); -- composition guarantees with the inner transactions layer (R16): every - write during VC time-travel is recorded back into the *current - branch's* log. - -The decorator inherits from `LinksDecoratorBase` in C# and -implements the same `LinksStorage` trait extracted in S2 in Rust, so it -can in turn be wrapped by `NamedTypesDecorator`, -`PinnedTypesDecorator`, and `PersistentTransformationDecorator` if a user -opts in. The order of composition is documented as: - -```text -PersistentTransformationDecorator -└── PinnedTypesDecorator - └── NamedTypesDecorator - └── VersionControlDecorator (optional, R11) - └── TransactionsDecorator (optional, R1-R10) - └── UnitedMemoryLinks (data store) - + - ┌── named transitions store (doublets) (R5) -``` - -Satisfies R11, R12, R13, R14, R15, R16. - -### Step S6 — CLI flags and library examples - -CLI (`clink`) additions in both implementations: - -- `--transactions ` — enable the transactions layer; `` is - the doublets log store (default: `.transitions.links`). -- `--commit-mode sync|async` — choose sync or async commits (R8). - Defaults to `sync` for safety. -- `--retention infinite|sized:|chunked::` — set the - retention policy (R6, R7). -- `--vc` — enable the version-control decorator (R11). -- `--branch ` — switch to a branch (creating it if `--branch-from - ` is also passed) (R13, R14). -- `--checkout ` — time-travel the data store to a specific - transition id, timestamp, or tag (R12). -- `--tag =` — create a tag (R15). -- `--list-branches`, `--list-tags`, `--log` — read-only inspection - commands. - -Library examples added under `examples/`: - -- `examples/transactions-csharp/` — minimal C# program that opens a links - store with the transactions decorator, begins a transaction, performs - a few CRUD operations, and either commits or rolls back. -- `examples/transactions-rust/` — Rust equivalent. -- `examples/version-control-csharp/` and - `examples/version-control-rust/` — branch, tag, and checkout demos. - -Satisfies R9, R17, R18, R19, R21. - -### Step S7 — Tests - -For each language: - -- Unit tests for commit, rollback, dispose-without-commit, nested - transactions (asserting current "not supported" behavior with a clear - error), and a stress test that performs random CRUD with random - commit/rollback decisions and asserts that the data store ends in the - same state as a reference Hash-based replay. -- Recovery tests: kill mid-write (via injected fault), reopen, assert the - layer recovers to the last fully-committed state. -- Retention tests for `Sized` and `Chunked` policies, asserting that no - un-applied transition is ever dropped (R7). -- Branch / tag / checkout tests that build a small history with two - branches and assert that switching back and forth produces byte-identical - database snapshots at every named point. -- Composition tests that stack `NamedTypesDecorator` / - `PinnedTypesDecorator` / `PersistentTransformationDecorator` on top of - the new layers and re-run the existing CRUD test suite, asserting no - regressions. - -Satisfies R20. - -### Step S8 — Documentation - -- Update `docs/REQUIREMENTS.md` to mark the optional transactions and - version-control entries as *implemented*. -- Update `docs/ARCHITECTURE.md` with the new composition stack - illustrated in S5. -- Update `docs/HOW-IT-WORKS.md` with a "Time travel and branching" - section that walks through a small example. -- Update both `csharp/README.md` and `rust/README.md` with the new CLI - flags and library APIs. -- Cross-link this case study from the documentation index. - -Satisfies R21, R22. +## 6. Implemented solution + +PR #95 implements the case-study plan in C# and Rust. The implementation +keeps the layers opt-in: without `--transactions` or `--vc`, the existing +links storage path is unchanged and no transaction or version-control +sidecar is created. + +### Transactions layer + +The C# `TransactionsDecorator` and Rust `transactions::TransactionsDecorator` +wrap the data store plus a second doublets store used as the transitions +log. Each write records a `Transition` with transaction id, sequence, +timestamp, and full before/after link state. Explicit transactions expose +`BeginTransaction()`, `Commit()`, `Rollback()`, and rollback-on-dispose in +C#; Rust exposes the same lifecycle as `begin_transaction()`, `commit()`, +and `rollback()` methods on the decorator. Auto transactions still wrap +one standalone write. + +The log is durable sidecar state: + +- transition records, commit markers, rollback markers, and applied markers + are persisted as names inside the transitions doublets store; +- recovery scans that sidecar on open, replays committed-but-unapplied + transitions, and rolls back incomplete transactions; +- `sync` and `async` commit modes are available; +- `infinite`, `sized:`, and `chunked::` retention policies are + implemented, with sized/chunked retention only removing applied entries. + +The C# recorder captures one transition per affected link in a logical write. +This matters for ACID atomicity because a delete can also update or remove +links that refer to the deleted link. `CreateAndUpdate(null, null)` continues +to log the existing create-plus-update sequence for compatibility with prior +checkout behavior. + +### Version-control layer + +The C# `VersionControlDecorator` and Rust +`version_control::VersionControlDecorator` sit above the transactions layer. +They add: + +- `Branch(name, from?)`, `SwitchBranch(name)`, and `ListBranches()`; +- `Tag(name, seq?)`, `TryGetTag(...)`, and `ListTags()`; +- `Checkout(seq)` for rewind/replay time travel; +- branch attribution for normal writes; +- explicit version-control transactions that defer branch metadata until the + inner transaction commits. + +Branch metadata, tags, current-branch, applied sequence, and transition-to- +branch attribution are persisted in the version-control sidecar doublets +store. Branches are represented as metadata over the shared transition +timeline rather than copied per-branch transition files. Checkout and branch +switching apply or revert existing transitions without recording new writes. + +### CLI and public APIs + +Both implementations expose the requested CLI controls: + +- `--transactions`, `--transactions-file`, `--commit-mode`, `--retention`, + and `--log`; +- `--vc`, `--vc-file`, `--branch`, `--branch-from`, `--checkout`, `--tag`, + `--list-branches`, and `--list-tags`. + +The same functionality is available through the C# library types and Rust +modules, so callers can compose these layers directly without going through +the CLI. + +### Verification added + +The PR includes unit and integration coverage for: + +- auto and explicit transaction commit/rollback; +- rollback-on-dispose and nested transaction rejection; +- update/delete reversal and recovery replay; +- sync/async commit modes; +- sized and chunked retention; +- branch creation, branch switching, checkout, tags, and metadata recovery; +- full-stack ACID rollback and commit/durability tests that run through the + version-control layer on top of the transactions layer in both C# and Rust. + +The C# durability coverage also reopens the data, transaction-log, and +version-control sidecar files after deterministic disposal of the file-backed +`NamedTypesDecorator` stores. ## 7. Risks and trade-offs | Risk | Mitigation | |------|------------| | Writing every transition into a *links* store, rather than a flat file, is slower than the upstream reference's `FileStream.Write(transition)`. | Acceptable for correctness; the issue explicitly asks for this. The log store can use the same `UnitedMemoryLinks` backend the main store already uses, so the overhead is well-understood. Async commit mode preserves the latency benefit of the flat-file approach for write-heavy workloads. | -| Branching requires forking the log file. If two branches diverge for a long time, the on-disk footprint is roughly `O(branches × transitions)`. | Documented limitation. Mirrors git's on-disk model. The `Chunked` retention policy can rotate inactive branches into archived chunks. | -| The Rust side currently has no explicit `LinksDecorator` trait, so adding the transactions layer requires extracting one. | The extraction is mechanical — the existing `LinkStorage`, `NamedLinks`, and `PinnedTypes` types already implement the same effective surface. We refactor in a single commit so the trait extraction is reviewable on its own. | +| Branches can diverge for a long time. | Branches share the transitions timeline and store only branch metadata plus new branch-specific transitions, avoiding full log copies. Retention policies still bound applied history where configured. | +| The Rust storage surface differs from the C# decorator hierarchy. | The Rust implementation wraps the existing `LinkStorage`, `NamedLinks`, and `PinnedTypes` behavior behind small transaction/version-control modules rather than importing a new storage framework. | | The upstream reference rejects nested transactions. The issue does not ask for nesting, but a future user might. | Out of scope for this PR; we throw a clear `NotSupportedException` (C#) / `Err(TransactionsError::NestedNotSupported)` (Rust) and document it. | | Time-travel via checkout has to *invert* all newer transitions when going back in time. If the log is very long this is O(n). | Documented as O(n); a `Snapshot(point)` API that materializes a checkpoint can be added later (event-sourcing style) if the linear cost becomes a problem in practice. | -| Auto-recovery on a crashed log is not in scope yet; the upstream reference also lacks it. | This case study calls it out (R10) and S4 provides a *correct* recovery story by validating commit markers on startup, but a full crash-stress test suite is deferred to a follow-up. | +| Crash recovery is hard to prove exhaustively. | Startup recovery is implemented and covered by reopen/replay tests. Full process-kill stress testing remains useful future hardening, but the current implementation no longer treats recovery as out of scope. | ## 8. Existing libraries we considered @@ -518,30 +318,28 @@ Satisfies R21, R22. | `Platform.Data.Doublets.Decorators.LinksDecoratorBase` (already a dependency) | **Yes** — direct reuse as the base class for the new C# decorators. | | `doublets` crate (already a dependency) | **Yes** — direct reuse for the Rust transitions store. | -## 9. Verification plan - -- `dotnet build csharp/Foundation.Data.Doublets.Cli.sln -c Release` - succeeds with the new project sources. -- `dotnet test csharp/Foundation.Data.Doublets.Cli.sln -c Release` passes - all existing tests *and* the new transactions / version-control tests. -- `cargo build --manifest-path rust/Cargo.toml --release` succeeds. -- `cargo test --manifest-path rust/Cargo.toml` passes all existing tests - *and* the new ones. -- `cargo fmt --check` and `cargo clippy --all-targets --all-features - -- -D warnings` keep passing. -- The CI on PR #95 keeps passing across `ubuntu-latest`, `macos-latest`, - and `windows-latest`. -- The new examples under `examples/transactions-*` and - `examples/version-control-*` each run end-to-end via `dotnet run` / - `cargo run` and demonstrate a committed transaction, a rolled-back - transaction, and a checkout/branch round-trip. - -## 10. Delivery plan on PR #95 - -This case study is the first commit on PR #95. The follow-up commits will -each correspond to one of the steps S2–S8 above, in order. The PR will -remain *draft* until S8 lands; only then will it be marked ready for -review. +## 9. Verification + +Local and CI verification for PR #95 covers both implementations: + +- `dotnet build csharp/Foundation.Data.Doublets.Cli.sln` +- `dotnet test csharp/Foundation.Data.Doublets.Cli.sln` +- `cargo fmt --check` +- `cargo clippy --all-targets --all-features -- -D warnings` +- `cargo test --manifest-path rust/Cargo.toml` + +The focused ACID suites are: + +- `csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs` +- `csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs` +- `rust/tests/transactions_decorator_tests.rs` +- `rust/tests/version_control_decorator_tests.rs` + +## 10. Delivery on PR #95 + +PR #95 contains the case study, implementation, tests, and documentation +updates for issue #94. It is ready for review once the latest local checks and +GitHub Actions checks pass after the final commits. Per the issue: *"Please plan and execute everything in a single pull request, you have unlimited time and context, as context auto-compacts diff --git a/rust/src/version_control/mod.rs b/rust/src/version_control/mod.rs index 1eec6c5..70d63ea 100644 --- a/rust/src/version_control/mod.rs +++ b/rust/src/version_control/mod.rs @@ -18,7 +18,7 @@ use anyhow::{bail, Result}; use crate::link::Link; use crate::named_types::{NamedTypes, NamedTypesDecorator}; -use crate::transactions::{TransactionsDecorator, Transition}; +use crate::transactions::{TransactionHandle, TransactionsDecorator, Transition}; /// Default name of the initial branch (analogous to git's `main`). pub const DEFAULT_BRANCH_NAME: &str = "main"; @@ -63,9 +63,16 @@ pub struct VersionControlDecorator { applied_link: u32, current_branch: String, current_applied: i64, + active_transaction: Option, trace: bool, } +#[derive(Debug, Clone)] +struct VersionControlTransactionState { + branch_name: String, + before_sequence: i64, +} + impl VersionControlDecorator { pub fn new( transactions: TransactionsDecorator, @@ -84,6 +91,7 @@ impl VersionControlDecorator { applied_link: 0, current_branch: DEFAULT_BRANCH_NAME.to_string(), current_applied: 0, + active_transaction: None, trace, }; decorator.recover()?; @@ -145,33 +153,80 @@ impl VersionControlDecorator { &self.branches_store } + pub fn begin_transaction(&mut self) -> Result { + if self.active_transaction.is_some() { + bail!("Nested version-control transactions are not supported."); + } + let before_sequence = self.transactions.last_logged_sequence(); + let branch_name = self.current_branch.clone(); + let handle = self.transactions.begin_transaction()?; + self.active_transaction = Some(VersionControlTransactionState { + branch_name, + before_sequence, + }); + Ok(handle) + } + + pub fn commit(&mut self) -> Result<()> { + let state = self + .active_transaction + .as_ref() + .cloned() + .ok_or_else(|| anyhow::anyhow!("No version-control transaction is open."))?; + self.transactions.commit()?; + self.active_transaction = None; + self.attribute_new_transitions_for_branch(state.before_sequence, &state.branch_name)?; + Ok(()) + } + + pub fn rollback(&mut self) -> Result<()> { + self.active_transaction + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No version-control transaction is open."))?; + self.transactions.rollback()?; + self.active_transaction = None; + Ok(()) + } + // -- Write API (attribute new transitions to the current branch) ---- pub fn create(&mut self, source: u32, target: u32) -> Result { let before_seq = self.transactions.last_logged_sequence(); let id = self.transactions.create(source, target)?; - self.attribute_new_transitions(before_seq)?; + if self.active_transaction.is_none() { + let branch = self.current_branch.clone(); + self.attribute_new_transitions_for_branch(before_seq, &branch)?; + } Ok(id) } pub fn update(&mut self, id: u32, source: u32, target: u32) -> Result { let before_seq = self.transactions.last_logged_sequence(); let result = self.transactions.update(id, source, target)?; - self.attribute_new_transitions(before_seq)?; + if self.active_transaction.is_none() { + let branch = self.current_branch.clone(); + self.attribute_new_transitions_for_branch(before_seq, &branch)?; + } Ok(result) } pub fn delete(&mut self, id: u32) -> Result { let before_seq = self.transactions.last_logged_sequence(); let result = self.transactions.delete(id)?; - self.attribute_new_transitions(before_seq)?; + if self.active_transaction.is_none() { + let branch = self.current_branch.clone(); + self.attribute_new_transitions_for_branch(before_seq, &branch)?; + } Ok(result) } pub fn create_and_update(&mut self, source: u32, target: u32) -> Result { let before_seq = self.transactions.last_logged_sequence(); let id = self.transactions.create_and_update(source, target)?; - self.attribute_new_transitions(before_seq)?; + if self.active_transaction.is_none() { + let branch = self.current_branch.clone(); + self.attribute_new_transitions_for_branch(before_seq, &branch)?; + } Ok(id) } @@ -202,33 +257,40 @@ impl VersionControlDecorator { self.transactions.ensure_created(id) } - fn attribute_new_transitions(&mut self, before_seq: i64) -> Result<()> { + fn attribute_new_transitions_for_branch( + &mut self, + before_seq: i64, + branch_name: &str, + ) -> Result<()> { let after_seq = self.transactions.last_logged_sequence(); if after_seq <= before_seq { return Ok(()); } - let branch_name = self.current_branch.clone(); for s in (before_seq + 1)..=after_seq { - self.transition_branches.insert(s, branch_name.clone()); + self.transition_branches.insert(s, branch_name.to_string()); let marker = format!("{TRANSITION_PREFIX}{s}:branch={branch_name}"); self.write_immutable_marker(&marker)?; } - if let Some(info) = self.branches.get(&branch_name).cloned() { + if let Some(info) = self.branches.get(branch_name).cloned() { let updated = BranchInfo { head: after_seq, ..info }; - self.branches.insert(branch_name.clone(), updated.clone()); + self.branches + .insert(branch_name.to_string(), updated.clone()); self.update_branch_link(&updated)?; } - self.current_applied = after_seq; - self.set_applied(after_seq)?; + if self.current_branch == branch_name { + self.current_applied = after_seq; + self.set_applied(after_seq)?; + } Ok(()) } // -- Branching ---------------------------------------------------- pub fn branch(&mut self, name: &str, from: Option) -> Result<()> { + self.ensure_no_open_transaction("branch")?; if name.trim().is_empty() { bail!("Branch name must not be empty."); } @@ -255,6 +317,7 @@ impl VersionControlDecorator { } pub fn switch_branch(&mut self, name: &str) -> Result<()> { + self.ensure_no_open_transaction("switch_branch")?; if !self.branches.contains_key(name) { bail!("Unknown branch '{name}'."); } @@ -268,6 +331,7 @@ impl VersionControlDecorator { } pub fn checkout(&mut self, sequence: i64) -> Result<()> { + self.ensure_no_open_transaction("checkout")?; if sequence < 0 { bail!("Sequence must be non-negative."); } @@ -285,6 +349,7 @@ impl VersionControlDecorator { } pub fn tag(&mut self, name: &str, sequence: Option) -> Result<()> { + self.ensure_no_open_transaction("tag")?; if name.trim().is_empty() { bail!("Tag name must not be empty."); } @@ -338,6 +403,13 @@ impl VersionControlDecorator { Ok(()) } + fn ensure_no_open_transaction(&self, operation: &str) -> Result<()> { + if self.active_transaction.is_some() { + bail!("{operation} is not allowed while a version-control transaction is open."); + } + Ok(()) + } + fn build_branch_seqs(&self, branch_name: &str) -> Vec { let mut visited: HashSet = HashSet::new(); self.build_branch_seqs_inner(branch_name, &mut visited) diff --git a/rust/tests/version_control_decorator_tests.rs b/rust/tests/version_control_decorator_tests.rs index b914212..3caad19 100644 --- a/rust/tests/version_control_decorator_tests.rs +++ b/rust/tests/version_control_decorator_tests.rs @@ -5,7 +5,7 @@ use anyhow::Result; use link_cli::{ - CommitMode, LogRetentionPolicy, NamedTypesDecorator, TransactionsDecorator, + CommitMode, Link, LogRetentionPolicy, NamedTypesDecorator, TransactionsDecorator, VersionControlDecorator, DEFAULT_BRANCH_NAME, }; use tempfile::NamedTempFile; @@ -232,3 +232,129 @@ fn duplicate_branch_throws() -> Result<()> { assert!(vc.branch("feature", None).is_err()); Ok(()) } + +#[test] +fn full_stack_acid_rollback_is_atomic_and_isolated() -> Result<()> { + let (mut vc, _guard) = make_vc(); + let baseline = snapshot(&vc); + let initial_sequence = vc.current_sequence(); + + vc.begin_transaction()?; + let a = vc.create_and_update(0, 0)?; + let b = vc.create_and_update(0, 0)?; + vc.update(a, b, b)?; + + assert!(vc.exists(a)); + assert!(vc.exists(b)); + assert!(vc.begin_transaction().is_err()); + assert!(vc.branch("blocked", None).is_err()); + + vc.rollback()?; + + assert_eq!(initial_sequence, vc.current_sequence()); + let main = vc + .list_branches() + .into_iter() + .find(|branch| branch.name == DEFAULT_BRANCH_NAME) + .expect("main branch must exist"); + assert_eq!(initial_sequence, main.head); + assert_eq!(baseline, snapshot(&vc)); + Ok(()) +} + +#[test] +fn full_stack_acid_commit_is_consistent_and_durable_across_reopen() -> Result<()> { + let data_file = NamedTempFile::new()?; + let log_file = NamedTempFile::new()?; + let vc_file = NamedTempFile::new()?; + let data_path = data_file.path().to_path_buf(); + let log_path = log_file.path().to_path_buf(); + let vc_path = vc_file.path().to_path_buf(); + + let (a, b, committed_sequence) = { + let data_links = NamedTypesDecorator::new(&data_path, false)?; + let log_links = NamedTypesDecorator::new(&log_path, false)?; + let vc_links = NamedTypesDecorator::new(&vc_path, false)?; + let tx = TransactionsDecorator::new( + data_links, + log_links, + LogRetentionPolicy::default(), + CommitMode::default(), + false, + )?; + let mut vc = VersionControlDecorator::new(tx, vc_links, false)?; + + vc.begin_transaction()?; + let a = vc.create_and_update(0, 0)?; + let b = vc.create_and_update(0, 0)?; + vc.update(a, b, b)?; + vc.commit()?; + + let committed_sequence = vc.current_sequence(); + assert!(committed_sequence >= 5); + assert_eq!( + vc.transactions().last_logged_sequence(), + vc.transactions().applied_sequence() + ); + let main = vc + .list_branches() + .into_iter() + .find(|branch| branch.name == DEFAULT_BRANCH_NAME) + .expect("main branch must exist"); + assert_eq!(committed_sequence, main.head); + + vc.tag("acid-commit", None)?; + vc.branch("audit", None)?; + vc.switch_branch("audit")?; + vc.delete(b)?; + assert!(!vc.exists(b)); + + vc.switch_branch(DEFAULT_BRANCH_NAME)?; + assert!(vc.exists(a)); + assert!(vc.exists(b)); + let restored = vc.get(a).copied().expect("link a must exist"); + assert_eq!(b, restored.source); + assert_eq!(b, restored.target); + vc.save()?; + + (a, b, committed_sequence) + }; + + let data_links = NamedTypesDecorator::new(&data_path, false)?; + let log_links = NamedTypesDecorator::new(&log_path, false)?; + let vc_links = NamedTypesDecorator::new(&vc_path, false)?; + let tx = TransactionsDecorator::new( + data_links, + log_links, + LogRetentionPolicy::default(), + CommitMode::default(), + false, + )?; + let reopened = VersionControlDecorator::new(tx, vc_links, false)?; + + assert_eq!( + Some(committed_sequence), + reopened.try_get_tag("acid-commit") + ); + assert!(reopened + .list_branches() + .iter() + .any(|branch| branch.name == "audit")); + assert_eq!(DEFAULT_BRANCH_NAME, reopened.current_branch()); + assert!(reopened.exists(a)); + assert!(reopened.exists(b)); + let restored = reopened.get(a).copied().expect("link a must exist"); + assert_eq!(b, restored.source); + assert_eq!(b, restored.target); + assert_eq!( + reopened.transactions().last_logged_sequence(), + reopened.transactions().applied_sequence() + ); + Ok(()) +} + +fn snapshot(vc: &VersionControlDecorator) -> Vec { + let mut links: Vec = vc.all().into_iter().copied().collect(); + links.sort_by_key(|link| (link.index, link.source, link.target)); + links +}