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/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/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 new file mode 100644 index 0000000..8f2b46c --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -0,0 +1,921 @@ +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 Dictionary(); + var observedOrder = new List(); + + WriteHandler wrapped = (before, after) => + { + 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) + { + if (!observed.TryGetValue(key, out var state)) + { + observedOrder.Add(key); + state = (beforeLink, afterLink); + } + else + { + state = (state.Before ?? beforeLink, afterLink); + } + observed[key] = state; + } + 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 key in observedOrder) + { + var state = observed[key]; + var before = state.Before ?? default; + var after = state.After ?? default; + 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 + { + if (transition.Before.Index == 0) + { + DeleteIfExists(transition.After.Index); + } + else + { + RestoreLink(transition.Before); + } + } + catch (Exception ex) + { + Trace($"Failed to revert transition seq={transition.Sequence}: {ex.Message}"); + } + } + + /// + /// 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 + { + if (transition.After.Index == 0) + { + DeleteIfExists(transition.Before.Index); + } + else + { + RestoreLink(transition.After); + } + + 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; + } + } + + 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); + 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); + } + } + } + } +} 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..bf9bba9 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs @@ -0,0 +1,631 @@ +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; } + ITransaction BeginTransaction(); + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + 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 VersionControlTransaction? _activeTransaction; + 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); + } + + 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) + { + 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(); + if (_activeTransaction is null) + { + 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) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Branch name must not be empty.", nameof(name)); + } + lock (_lock) + { + EnsureNoOpenTransactionLocked(nameof(Branch)); + 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) + { + EnsureNoOpenTransactionLocked(nameof(SwitchBranch)); + 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) + { + EnsureNoOpenTransactionLocked(nameof(Checkout)); + 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) + { + EnsureNoOpenTransactionLocked(nameof(Tag)); + 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 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)); + } + + 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}"); + } + + 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 new file mode 100644 index 0000000..ed05558 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs @@ -0,0 +1,272 @@ +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 + { + dataLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + } + } + + 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(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + } + } + + private static void Cleanup(string path) + { + if (File.Exists(path)) File.Delete(path); + } + } +} 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..7d4ed34 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs @@ -0,0 +1,379 @@ +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")); + }); + } + + [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(); + 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(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + vcLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(vcFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); + } + } + + 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/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; + } } ); 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..eb2f65e 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,65 @@ 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; + 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). +- 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. +- 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` +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. +- **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`. + +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..cddc69f 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -171,6 +171,76 @@ 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 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`): + +- `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..7c9e77f 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 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 new file mode 100644 index 0000000..bdf7e1e --- /dev/null +++ b/docs/case-studies/issue-94/README.md @@ -0,0 +1,347 @@ +# 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, 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 + +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* + 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 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 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. | +| 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 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 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) + +| 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` 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 + +### 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. 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. 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. | +| 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. | +| 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 + +| 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 + +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 +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); + } + }; +} 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" 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 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. 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/lib.rs b/rust/src/lib.rs index a90bcdb..18b2a75 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -33,7 +33,9 @@ mod query_processor; mod query_processor_substitution; 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; @@ -50,4 +52,9 @@ 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; +pub use version_control::{BranchInfo, VersionControlDecorator, DEFAULT_BRANCH_NAME}; 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 new file mode 100644 index 0000000..b9d029f --- /dev/null +++ b/rust/src/transactions/mod.rs @@ -0,0 +1,797 @@ +//! 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). + +mod types; + +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}; + +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). +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) + } + + 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 { + 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/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:"; diff --git a/rust/src/version_control/mod.rs b/rust/src/version_control/mod.rs new file mode 100644 index 0000000..70d63ea --- /dev/null +++ b/rust/src/version_control/mod.rs @@ -0,0 +1,664 @@ +//! 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::{TransactionHandle, 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, + active_transaction: Option, + trace: bool, +} + +#[derive(Debug, Clone)] +struct VersionControlTransactionState { + branch_name: String, + before_sequence: i64, +} + +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, + active_transaction: None, + 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 + } + + 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)?; + 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)?; + 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)?; + 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)?; + 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 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() + } + + 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_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(()); + } + for s in (before_seq + 1)..=after_seq { + 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() { + let updated = BranchInfo { + head: after_seq, + ..info + }; + self.branches + .insert(branch_name.to_string(), updated.clone()); + self.update_branch_link(&updated)?; + } + 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."); + } + 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<()> { + self.ensure_no_open_transaction("switch_branch")?; + 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<()> { + self.ensure_no_open_transaction("checkout")?; + 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<()> { + self.ensure_no_open_transaction("tag")?; + 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 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) + } + + 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 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)?; + } + } + 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/cli_arguments_tests.rs b/rust/tests/cli_arguments_tests.rs index fc717df..08c780c 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:?}"), } } @@ -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..c70c408 --- /dev/null +++ b/rust/tests/cli_transactions_and_vc_tests.rs @@ -0,0 +1,226 @@ +//! 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(()) +} diff --git a/rust/tests/transactions_decorator_tests.rs b/rust/tests/transactions_decorator_tests.rs new file mode 100644 index 0000000..1dbd0c4 --- /dev/null +++ b/rust/tests/transactions_decorator_tests.rs @@ -0,0 +1,305 @@ +//! 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(()) +} diff --git a/rust/tests/version_control_decorator_tests.rs b/rust/tests/version_control_decorator_tests.rs new file mode 100644 index 0000000..3caad19 --- /dev/null +++ b/rust/tests/version_control_decorator_tests.rs @@ -0,0 +1,360 @@ +//! 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, Link, 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(()) +} + +#[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 +}