diff --git a/.gitignore b/.gitignore index 7361c97..e7c353e 100644 --- a/.gitignore +++ b/.gitignore @@ -402,3 +402,7 @@ FodyWeavers.xsd *.received.* *.lscache + +# Agent skills (local-only) +.scratch/ +docs/agents/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ec05a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +## Agent skills + +### Issue tracker + +Issues live as local markdown files under `.scratch/` (gitignored). See `docs/agents/issue-tracker.md`. + +### Triage labels + +Default triage role strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context layout — `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`. diff --git a/harmony.sln b/harmony.sln index a44dc0d..129cce0 100644 --- a/harmony.sln +++ b/harmony.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Tests", "src\SI EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Linq2db", "src\SIL.Harmony.Linq2db\SIL.Harmony.Linq2db.csproj", "{8EB807F6-C548-4016-856E-2ACCA5603036}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Refs", "src\SIL.Harmony.Refs\SIL.Harmony.Refs.csproj", "{C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{321635F9-11D9-4B45-A2F8-F3B70E9A4196}" ProjectSection(SolutionItems) = preProject src\Directory.Build.props = src\Directory.Build.props @@ -55,5 +57,9 @@ Global {8EB807F6-C548-4016-856E-2ACCA5603036}.Debug|Any CPU.Build.0 = Debug|Any CPU {8EB807F6-C548-4016-856E-2ACCA5603036}.Release|Any CPU.ActiveCfg = Release|Any CPU {8EB807F6-C548-4016-856E-2ACCA5603036}.Release|Any CPU.Build.0 = Release|Any CPU + {C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/SIL.Harmony.Refs/BranchAssignment.cs b/src/SIL.Harmony.Refs/BranchAssignment.cs new file mode 100644 index 0000000..468ed05 --- /dev/null +++ b/src/SIL.Harmony.Refs/BranchAssignment.cs @@ -0,0 +1,32 @@ +namespace SIL.Harmony.Refs; + +/// +/// Where a newly authored commit should be assigned. +/// +public readonly record struct BranchAssignment +{ + private BranchAssignment(BranchAssignmentKind kind, Guid? branchId) + { + Kind = kind; + BranchId = branchId; + } + + public BranchAssignmentKind Kind { get; } + public Guid? BranchId { get; } + + /// Use the current local checkout (default). + public static BranchAssignment FromCheckout { get; } = new(BranchAssignmentKind.FromCheckout, null); + + /// Force main line (no branch metadata). + public static BranchAssignment Main { get; } = new(BranchAssignmentKind.Main, null); + + /// Force a specific branch. + public static BranchAssignment ToBranch(Guid branchId) => new(BranchAssignmentKind.Branch, branchId); +} + +public enum BranchAssignmentKind +{ + FromCheckout, + Main, + Branch +} diff --git a/src/SIL.Harmony.Refs/Changes/CreateBranchChange.cs b/src/SIL.Harmony.Refs/Changes/CreateBranchChange.cs new file mode 100644 index 0000000..f994fc2 --- /dev/null +++ b/src/SIL.Harmony.Refs/Changes/CreateBranchChange.cs @@ -0,0 +1,21 @@ +using SIL.Harmony.Changes; +using SIL.Harmony.Entities; +using SIL.Harmony.Refs.Entities; + +namespace SIL.Harmony.Refs.Changes; + +public class CreateBranchChange(Guid entityId, string name) : CreateChange(entityId), IPolyType +{ + public string Name { get; } = name; + + public override ValueTask NewEntity(Commit commit, IChangeContext context) + { + return ValueTask.FromResult(new Branch + { + Id = EntityId, + Name = Name + }); + } + + public static string TypeName => "create:branch"; +} diff --git a/src/SIL.Harmony.Refs/Changes/CreateTagChange.cs b/src/SIL.Harmony.Refs/Changes/CreateTagChange.cs new file mode 100644 index 0000000..479c752 --- /dev/null +++ b/src/SIL.Harmony.Refs/Changes/CreateTagChange.cs @@ -0,0 +1,23 @@ +using SIL.Harmony.Changes; +using SIL.Harmony.Entities; +using SIL.Harmony.Refs.Entities; + +namespace SIL.Harmony.Refs.Changes; + +public class CreateTagChange(Guid entityId, string name, Guid targetCommitId) : CreateChange(entityId), IPolyType +{ + public string Name { get; } = name; + public Guid TargetCommitId { get; } = targetCommitId; + + public override ValueTask NewEntity(Commit commit, IChangeContext context) + { + return ValueTask.FromResult(new Tag + { + Id = EntityId, + Name = Name, + TargetCommitId = TargetCommitId + }); + } + + public static string TypeName => "create:tag"; +} diff --git a/src/SIL.Harmony.Refs/Changes/MergeBranchChange.cs b/src/SIL.Harmony.Refs/Changes/MergeBranchChange.cs new file mode 100644 index 0000000..cdb0f20 --- /dev/null +++ b/src/SIL.Harmony.Refs/Changes/MergeBranchChange.cs @@ -0,0 +1,20 @@ +using SIL.Harmony.Changes; +using SIL.Harmony.Entities; +using SIL.Harmony.Refs.Entities; + +namespace SIL.Harmony.Refs.Changes; + +/// +/// Main-line commit that incorporates a branch into main visibility and deletes the branch entity. +/// Does not rewrite branch commit payloads; materialization starts including those commits after this change. +/// +public class MergeBranchChange(Guid entityId) : EditChange(entityId), IPolyType +{ + public override ValueTask ApplyChange(Branch entity, IChangeContext context) + { + entity.DeletedAt = context.Commit.DateTime; + return ValueTask.CompletedTask; + } + + public static string TypeName => "merge:branch"; +} diff --git a/src/SIL.Harmony.Refs/Changes/MoveTagChange.cs b/src/SIL.Harmony.Refs/Changes/MoveTagChange.cs new file mode 100644 index 0000000..02a226b --- /dev/null +++ b/src/SIL.Harmony.Refs/Changes/MoveTagChange.cs @@ -0,0 +1,18 @@ +using SIL.Harmony.Changes; +using SIL.Harmony.Entities; +using SIL.Harmony.Refs.Entities; + +namespace SIL.Harmony.Refs.Changes; + +public class MoveTagChange(Guid entityId, Guid targetCommitId) : EditChange(entityId), IPolyType +{ + public Guid TargetCommitId { get; } = targetCommitId; + + public override ValueTask ApplyChange(Tag entity, IChangeContext context) + { + entity.TargetCommitId = TargetCommitId; + return ValueTask.CompletedTask; + } + + public static string TypeName => "move:tag"; +} diff --git a/src/SIL.Harmony.Refs/CheckoutMaterializationFilter.cs b/src/SIL.Harmony.Refs/CheckoutMaterializationFilter.cs new file mode 100644 index 0000000..a2dac44 --- /dev/null +++ b/src/SIL.Harmony.Refs/CheckoutMaterializationFilter.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using SIL.Harmony.Db; +using SIL.Harmony.Refs.Changes; + +namespace SIL.Harmony.Refs; + +/// +/// Materializes commits visible for the current local checkout: +/// main line (no branch id), commits for incorporated (merged) branches on main, +/// plus commits scoped to the checked-out branch when on a branch, +/// or commits at-or-before a tag tip with visibility evaluated at that tip. +/// +public sealed class CheckoutMaterializationFilter : ICommitMaterializationFilter, IMaterializationApplyWindow +{ + private readonly HashSet _incorporatedBranchIds = []; + private Commit? _asOfTip; + + public RefCheckout Checkout { get; set; } = RefCheckout.Main; + + public bool HasAsOfTip => _asOfTip is not null; + + /// + /// The commit the current tag checkout is pinned to, or null when not pinned to a tip. + /// Lets the roll-forward listener skip rematerialization when the tag tip has not moved. + /// + public Guid? AsOfTipId => _asOfTip?.Id; + + /// + /// When set (tag checkout), only commits at or before this tip are included, + /// and merge incorporation is evaluated only through this tip. + /// + public void SetAsOfTip(Commit tip) => _asOfTip = tip; + + public void ClearAsOfTip() => _asOfTip = null; + + public bool Include(Commit commit) + { + // Keep the active tag entity materializable even when create/move commits are after the tip. + if (Checkout is TagCheckout tag && TouchesTag(commit, tag.TagId)) + return true; + + if (_asOfTip is { } tip && commit.CompareKey.CompareTo(tip.CompareKey) > 0) + return false; + + var branchId = RefMetadata.GetBranchId(commit.Metadata); + if (branchId is null) return true; + + return Checkout switch + { + BranchCheckout branch => branchId == branch.BranchId || _incorporatedBranchIds.Contains(branchId.Value), + TagCheckout when _asOfTip is { } asOfTip && RefMetadata.GetBranchId(asOfTip.Metadata) is Guid tipBranch => + branchId == tipBranch || _incorporatedBranchIds.Contains(branchId.Value), + _ => _incorporatedBranchIds.Contains(branchId.Value) + }; + } + + async Task> IMaterializationApplyWindow.PrepareApplyWindowAsync( + CrdtRepository repo, + SortedSet commitsToApply) + { + await RefreshIncorporatedBranchIds(repo); + + var mergedInWindow = commitsToApply + .SelectMany(MergesIn) + .Select(m => m.EntityId) + .ToHashSet(); + if (mergedInWindow.Count == 0) return commitsToApply; + + // CurrentCommits is DefaultOrder (oldest first). First hit is the window start: + // earliest merged-branch commit, or the apply-window head if that is older. + var applyMinId = commitsToApply.Min!.Id; + var allCommits = await repo.CurrentCommits() + .Include(c => c.ChangeEntities) + .ToListAsync(); + + for (var i = 0; i < allCommits.Count; i++) + { + var commit = allCommits[i]; + var branchId = RefMetadata.GetBranchId(commit.Metadata); + if (commit.Id == applyMinId || + (branchId is Guid id && mergedInWindow.Contains(id))) + { + return allCommits.Skip(i).ToSortedSet(); + } + } + + return commitsToApply; + } + + private async Task RefreshIncorporatedBranchIds(CrdtRepository repo) + { + _incorporatedBranchIds.Clear(); + // Scoping to the tip bounds GetEntityIdsForChangeType to merges at or before it (see issue 15), + // so a single query serves both the tag (as-of) and unscoped cases. + if (_asOfTip is { } tip) repo = repo.GetScopedRepository(tip); + _incorporatedBranchIds.UnionWith(await repo.GetEntityIdsForChangeType()); + } + + private static IEnumerable MergesIn(Commit commit) => + commit.ChangeEntities + .Select(ce => ce.Change) + .OfType(); + + private static bool TouchesTag(Commit commit, Guid tagId) => + commit.ChangeEntities.Any(ce => ce.EntityId == tagId && + (ce.Change is CreateTagChange or MoveTagChange)); +} diff --git a/src/SIL.Harmony.Refs/CheckoutRefsHandler.cs b/src/SIL.Harmony.Refs/CheckoutRefsHandler.cs new file mode 100644 index 0000000..e1ef19f --- /dev/null +++ b/src/SIL.Harmony.Refs/CheckoutRefsHandler.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace SIL.Harmony.Refs; + +/// +/// The refs bridge into 's generic extension points, so clients can author +/// and sync through directly and still get branch/tag behaviour without the +/// wrapper. +/// +/// As an it stamps the current checkout's branch assignment +/// on locally-authored commits (or rejects authoring on a tag). +/// As an it rolls an active tag checkout forward after +/// any apply — local or synced — when the tag's tip moved. +/// +/// is resolved lazily (only when rolling forward) because the roll-forward +/// depends on it, yet it is already constructed by the time an apply completes; requiring it in the +/// constructor would form a cycle, since this same instance is also the constructor-injected interceptor. +/// +public sealed class CheckoutRefsHandler( + CheckoutMaterializationFilter filter, + IOptions config, + IServiceProvider serviceProvider) + : ICommitInterceptor, ICommitAppliedListener +{ + public void OnCommitAuthored(Commit commit) + { + // Explicit per-call override or a ref-lifecycle assignment already decided this commit. + // ConsumeAssignment also clears the transient marker so it is never persisted or synced. + if (RefMetadata.ConsumeAssignment(commit.Metadata)) return; + + switch (filter.Checkout) + { + case BranchCheckout branch: + RefMetadata.SetBranchId(commit.Metadata, branch.BranchId); + break; + case TagCheckout when !config.Value.AllowAuthoringOnTagToMain: + throw new InvalidOperationException( + "Authoring is not allowed while checked out on a tag. Set AllowAuthoringOnTagToMain to write to main, " + + "author with an explicit BranchAssignment, or checkout main/branch first."); + // MainCheckout, or a tag checkout with AllowAuthoringOnTagToMain: author to main (no branch id). + } + } + + public async Task OnCommitsAppliedAsync(IReadOnlyCollection commits) + { + // Roll-forward only concerns tag checkouts; a main/branch view never moves on apply. + if (filter.Checkout is not TagCheckout tag) return; + + var dataModel = serviceProvider.GetRequiredService(); + var tip = await TagTipResolver.ResolveTagTip(dataModel, tag.TagId); + // Skip rematerialization unless the tag's tip actually moved. + if (filter.AsOfTipId == tip.Id) return; + + filter.SetAsOfTip(tip); + await dataModel.RegenerateSnapshots(); + } +} diff --git a/src/SIL.Harmony.Refs/Entities/Branch.cs b/src/SIL.Harmony.Refs/Entities/Branch.cs new file mode 100644 index 0000000..46e332e --- /dev/null +++ b/src/SIL.Harmony.Refs/Entities/Branch.cs @@ -0,0 +1,26 @@ +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Refs.Entities; + +/// +/// A named line of work. Display is not unique; identity is . +/// +public class Branch : IObjectBase +{ + public Guid Id { get; init; } + public required string Name { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public Guid[] GetReferences() => []; + + public void RemoveReference(Guid id, CommitBase commit) + { + } + + public IObjectBase Copy() => new Branch + { + Id = Id, + Name = Name, + DeletedAt = DeletedAt + }; +} diff --git a/src/SIL.Harmony.Refs/Entities/Tag.cs b/src/SIL.Harmony.Refs/Entities/Tag.cs new file mode 100644 index 0000000..9ad2b45 --- /dev/null +++ b/src/SIL.Harmony.Refs/Entities/Tag.cs @@ -0,0 +1,31 @@ +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Refs.Entities; + +/// +/// A named movable pointer to a commit. Display is not unique; identity is . +/// Polymorphic type name is harmony:tag to avoid colliding with app-domain tag types. +/// +public class Tag : IObjectBase +{ + static string IPolyType.TypeName => "harmony:tag"; + + public Guid Id { get; init; } + public required string Name { get; set; } + public Guid TargetCommitId { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public Guid[] GetReferences() => []; + + public void RemoveReference(Guid id, CommitBase commit) + { + } + + public IObjectBase Copy() => new Tag + { + Id = Id, + Name = Name, + TargetCommitId = TargetCommitId, + DeletedAt = DeletedAt + }; +} diff --git a/src/SIL.Harmony.Refs/HarmonyRefsKernel.cs b/src/SIL.Harmony.Refs/HarmonyRefsKernel.cs new file mode 100644 index 0000000..96ecc89 --- /dev/null +++ b/src/SIL.Harmony.Refs/HarmonyRefsKernel.cs @@ -0,0 +1,43 @@ +using SIL.Harmony.Refs.Changes; +using SIL.Harmony.Refs.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace SIL.Harmony.Refs; + +public static class HarmonyRefsKernel +{ + /// + /// Registers branch/tag ref entities and change types. Sets a main-line-only materialization filter + /// as a fallback when is not used. + /// + public static CrdtConfig AddHarmonyRefs(this CrdtConfig config) + { + config.ObjectTypeListBuilder.DefaultAdapter().Add(); + config.ObjectTypeListBuilder.DefaultAdapter().Add(builder => builder.ToTable("HarmonyTags")); + config.ChangeTypeListBuilder.Add(); + config.ChangeTypeListBuilder.Add(); + config.ChangeTypeListBuilder.Add(); + config.ChangeTypeListBuilder.Add(); + config.CommitMaterializationFilter = MainLineOnlyMaterializationFilter.Instance; + return config; + } + + /// + /// Registers checkout-aware materialization and . + /// Prefer this for apps that switch between main and branch views. + /// + public static IServiceCollection AddHarmonyRefsDataModel(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(sp => + sp.GetRequiredService()); + // One handler instance serves both extension points: the authoring interceptor and the + // post-apply roll-forward listener. + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + return services; + } +} diff --git a/src/SIL.Harmony.Refs/MainLineOnlyMaterializationFilter.cs b/src/SIL.Harmony.Refs/MainLineOnlyMaterializationFilter.cs new file mode 100644 index 0000000..d67ec94 --- /dev/null +++ b/src/SIL.Harmony.Refs/MainLineOnlyMaterializationFilter.cs @@ -0,0 +1,14 @@ +namespace SIL.Harmony.Refs; + +/// +/// Materializes only main-line commits (no ). +/// Used as the fallback when checkout-aware DI is not registered. +/// Prefer via AddHarmonyRefsDataModel for branch views. +/// +public sealed class MainLineOnlyMaterializationFilter : ICommitMaterializationFilter +{ + public static MainLineOnlyMaterializationFilter Instance { get; } = new(); + + public bool Include(Commit commit) => + RefMetadata.GetBranchId(commit.Metadata) is null; +} diff --git a/src/SIL.Harmony.Refs/RefCheckout.cs b/src/SIL.Harmony.Refs/RefCheckout.cs new file mode 100644 index 0000000..9b1a93b --- /dev/null +++ b/src/SIL.Harmony.Refs/RefCheckout.cs @@ -0,0 +1,19 @@ +namespace SIL.Harmony.Refs; + +/// +/// Local checkout selection. Not synced; affects authoring defaults and materialization views. +/// +public abstract record RefCheckout +{ + public static RefCheckout Main { get; } = new MainCheckout(); + + public static RefCheckout ForBranch(Guid branchId) => new BranchCheckout(branchId); + + public static RefCheckout ForTag(Guid tagId) => new TagCheckout(tagId); +} + +public sealed record MainCheckout : RefCheckout; + +public sealed record BranchCheckout(Guid BranchId) : RefCheckout; + +public sealed record TagCheckout(Guid TagId) : RefCheckout; diff --git a/src/SIL.Harmony.Refs/RefMetadata.cs b/src/SIL.Harmony.Refs/RefMetadata.cs new file mode 100644 index 0000000..5a3346b --- /dev/null +++ b/src/SIL.Harmony.Refs/RefMetadata.cs @@ -0,0 +1,74 @@ +namespace SIL.Harmony.Refs; + +/// +/// Well-known keys for the refs layer. +/// +public static class RefMetadata +{ + /// + /// Immutable branch assignment for a commit. Absent or empty means main line. + /// + public const string BranchIdKey = "harmony.branchId"; + + /// + /// Transient authoring-time marker: distinguishes an explicit assignment (including to main, + /// which has no ) from an unassigned commit, so the authoring + /// interceptor knows to leave the commit alone rather than derive from the checkout. It is + /// consumed (removed) by the interceptor via before the commit + /// is persisted, so it never lands in stored or synced metadata. + /// + private const string BranchAssignedKey = "harmony.branchAssigned"; + + public static Guid? GetBranchId(CommitMetadata metadata) + { + var value = metadata[BranchIdKey]; + return Guid.TryParse(value, out var id) ? id : null; + } + + public static void SetBranchId(CommitMetadata metadata, Guid? branchId) + { + if (branchId is null) + { + metadata.ExtraMetadata.Remove(BranchIdKey); + return; + } + + metadata[BranchIdKey] = branchId.Value.ToString(); + } + + /// + /// Reads and clears the transient assignment marker, returning whether the commit carried a + /// deliberate assignment. Called once by the authoring interceptor so the marker is consumed + /// before persistence and never synced. + /// + internal static bool ConsumeAssignment(CommitMetadata metadata) => + metadata.ExtraMetadata.Remove(BranchAssignedKey); + + /// + /// Records an explicit per-call branch assignment on so it takes + /// precedence over the current checkout when authoring through . + /// and mark the commit; + /// leaves it unmarked so the checkout is used. + /// The marker is transient — the authoring interceptor consumes it, so it never persists. + /// Returns the same metadata for fluent use. + /// + public static CommitMetadata SetAssignment(CommitMetadata metadata, BranchAssignment assignment) + { + switch (assignment.Kind) + { + case BranchAssignmentKind.Main: + SetBranchId(metadata, null); + metadata[BranchAssignedKey] = bool.TrueString; + break; + case BranchAssignmentKind.Branch: + SetBranchId(metadata, assignment.BranchId); + metadata[BranchAssignedKey] = bool.TrueString; + break; + case BranchAssignmentKind.FromCheckout: + // Leave unmarked: the interceptor derives the branch id from the checkout. + break; + } + + return metadata; + } +} diff --git a/src/SIL.Harmony.Refs/RefsDataModel.cs b/src/SIL.Harmony.Refs/RefsDataModel.cs new file mode 100644 index 0000000..5fe2978 --- /dev/null +++ b/src/SIL.Harmony.Refs/RefsDataModel.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Options; +using SIL.Harmony.Refs.Changes; +using SIL.Harmony.Refs.Entities; + +namespace SIL.Harmony.Refs; + +/// +/// Checkout switching and ref lifecycle over . +/// Authoring and sync go through directly; the refs handler stamps +/// branch assignment on commit and rolls tag checkouts forward after apply. +/// Checkout state lives on (single source of truth); +/// changing it rematerializes snapshots for the new view. +/// +public class RefsDataModel(DataModel dataModel, CheckoutMaterializationFilter filter, IOptions config) +{ + public DataModel DataModel { get; } = dataModel; + + public RefCheckout Checkout => filter.Checkout; + + /// + /// When true, authoring on a tag checkout writes to main. Default is false (rejected). + /// Configured via . + /// + public bool AllowAuthoringOnTagToMain => config.Value.AllowAuthoringOnTagToMain; + + public async Task CheckoutMain() + { + if (Checkout is MainCheckout && !filter.HasAsOfTip) return; + filter.ClearAsOfTip(); + filter.Checkout = RefCheckout.Main; + await DataModel.RegenerateSnapshots(); + } + + public async Task CheckoutBranch(Guid branchId) + { + if (Checkout is BranchCheckout current && current.BranchId == branchId && !filter.HasAsOfTip) + return; + + filter.ClearAsOfTip(); + filter.Checkout = RefCheckout.ForBranch(branchId); + await DataModel.RegenerateSnapshots(); + } + + public async Task CheckoutTag(Guid tagId) + { + var tip = await TagTipResolver.ResolveTagTip(DataModel, tagId); + filter.Checkout = RefCheckout.ForTag(tagId); + filter.SetAsOfTip(tip); + await DataModel.RegenerateSnapshots(); + } + + public Task CreateBranch(Guid clientId, Guid branchId, string name) => + DataModel.AddChange(clientId, new CreateBranchChange(branchId, name), RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + public Task CreateTag(Guid clientId, Guid tagId, string name, Guid targetCommitId) => + DataModel.AddChange(clientId, new CreateTagChange(tagId, name, targetCommitId), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + public Task MoveTag(Guid clientId, Guid tagId, Guid targetCommitId) => + DataModel.AddChange(clientId, new MoveTagChange(tagId, targetCommitId), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + public IAsyncEnumerable ListBranches() => DataModel.QueryLatest(); + + public IAsyncEnumerable ListTags() => DataModel.QueryLatest(); + + /// + /// Incorporates into main visibility and deletes the branch entity. + /// Authored as a main-line commit; materialization expands from the earliest branch commit. + /// + public async Task MergeBranch(Guid clientId, Guid branchId) + { + await CheckoutMain(); + return await DataModel.AddChange(clientId, new MergeBranchChange(branchId), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + } +} diff --git a/src/SIL.Harmony.Refs/SIL.Harmony.Refs.csproj b/src/SIL.Harmony.Refs/SIL.Harmony.Refs.csproj new file mode 100644 index 0000000..1bebbfd --- /dev/null +++ b/src/SIL.Harmony.Refs/SIL.Harmony.Refs.csproj @@ -0,0 +1,17 @@ + + + + SIL.Harmony.Refs + true + Optional branches and tags layer for SIL.Harmony. + + + + + + + + + + + diff --git a/src/SIL.Harmony.Refs/TagTipResolver.cs b/src/SIL.Harmony.Refs/TagTipResolver.cs new file mode 100644 index 0000000..ed6a307 --- /dev/null +++ b/src/SIL.Harmony.Refs/TagTipResolver.cs @@ -0,0 +1,14 @@ +using SIL.Harmony.Refs.Entities; + +namespace SIL.Harmony.Refs; + +internal static class TagTipResolver +{ + public static async Task ResolveTagTip(DataModel dataModel, Guid tagId) + { + var tag = await dataModel.GetLatest(tagId) + ?? throw new InvalidOperationException($"Tag {tagId} was not found."); + return await dataModel.GetCommit(tag.TargetCommitId); + } +} + diff --git a/src/SIL.Harmony.Tests/CommitMaterializationFilterTests.cs b/src/SIL.Harmony.Tests/CommitMaterializationFilterTests.cs new file mode 100644 index 0000000..bca227b --- /dev/null +++ b/src/SIL.Harmony.Tests/CommitMaterializationFilterTests.cs @@ -0,0 +1,108 @@ +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Sample.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace SIL.Harmony.Tests; + +public class CommitMaterializationFilterTests : DataModelTestBase +{ + public CommitMaterializationFilterTests() : base(configure: services => + { + services.Configure(config => + { + config.CommitMaterializationFilter = new ExcludeMarkedCommitsFilter(); + }); + }) + { + } + + [Fact] + public async Task ExcludedCommitDoesNotAffectGetLatest() + { + var entityId = Guid.NewGuid(); + + await DataModel.AddChange(_localClientId, SetWord(entityId, "included")); + var excluded = await DataModel.AddChange( + _localClientId, + SetWord(entityId, "excluded"), + new CommitMetadata { ["materialize"] = "false" }); + + var word = await DataModel.GetLatest(entityId); + word!.Text.Should().Be("included"); + // Excluded commits are still stored; only materialization skips them + DbContext.Commits.Should().Contain(c => c.Id == excluded.Id); + } + + [Fact] + public async Task LateArrivingIncludedCommitReplaysFilteredHistory() + { + var entityId = Guid.NewGuid(); + + var first = await DataModel.AddChange(_localClientId, SetWord(entityId, "first")); + await DataModel.AddChange( + _localClientId, + SetWord(entityId, "skipped"), + new CommitMetadata { ["materialize"] = "false" }); + + (await DataModel.GetLatest(entityId))!.Text.Should().Be("first"); + + var late = new Commit + { + ClientId = _localClientId, + HybridDateTime = new HybridDateTime(first.DateTime.AddHours(1), 0), + Metadata = new CommitMetadata() + }; + late.ChangeEntities.Add(new ChangeEntity + { + Change = SetWord(entityId, "late"), + Index = 0, + CommitId = late.Id, + EntityId = entityId + }); + await AddCommitsViaSync([late]); + + var word = await DataModel.GetLatest(entityId); + // Included apply order: first → late; skipped never materializes + word!.Text.Should().Be("late"); + } + + [Fact] + public async Task LateArrivingExcludedCommitDoesNotDuplicateLaterSnapshots() + { + var entityId = Guid.NewGuid(); + + var first = await DataModel.AddChange(_localClientId, SetWord(entityId, "first")); + await DataModel.AddChange(_localClientId, SetWord(entityId, "third")); + (await DataModel.GetLatest(entityId))!.Text.Should().Be("third"); + var snapshotCountBefore = DbContext.Snapshots.Count(s => s.EntityId == entityId); + + var excluded = new Commit + { + ClientId = _localClientId, + HybridDateTime = new HybridDateTime(first.DateTime.AddHours(1), 0), + Metadata = new CommitMetadata { ["materialize"] = "false" } + }; + excluded.ChangeEntities.Add(new ChangeEntity + { + Change = SetWord(entityId, "excluded-middle"), + Index = 0, + CommitId = excluded.Id, + EntityId = entityId + }); + await AddCommitsViaSync([excluded]); + + (await DataModel.GetLatest(entityId))!.Text.Should().Be("third"); + DbContext.Snapshots.Count(s => s.EntityId == entityId).Should().Be(snapshotCountBefore); + DbContext.Commits.Should().Contain(c => c.Id == excluded.Id); + } + + /// + /// Excludes commits marked with ExtraMetadata materialize=false. + /// + private sealed class ExcludeMarkedCommitsFilter : ICommitMaterializationFilter + { + public bool Include(Commit commit) => + commit.Metadata["materialize"] != "false"; + } +} diff --git a/src/SIL.Harmony.Tests/DataModelTestBase.cs b/src/SIL.Harmony.Tests/DataModelTestBase.cs index 80402eb..57b73aa 100644 --- a/src/SIL.Harmony.Tests/DataModelTestBase.cs +++ b/src/SIL.Harmony.Tests/DataModelTestBase.cs @@ -20,6 +20,10 @@ public class DataModelTestBase : IAsyncLifetime public readonly SampleDbContext DbContext; protected readonly MockTimeProvider MockTimeProvider = new(); + public Guid LocalClientId => _localClientId; + + public T GetRequiredService() where T : notnull => _services.GetRequiredService(); + public DataModelTestBase(bool saveToDisk = false, bool alwaysValidate = true, Action? configure = null, bool performanceTest = false) : this(saveToDisk ? new SqliteConnection("Data Source=test.db") diff --git a/src/SIL.Harmony.Tests/Refs/BranchCheckoutViewTests.cs b/src/SIL.Harmony.Tests/Refs/BranchCheckoutViewTests.cs new file mode 100644 index 0000000..70c1565 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/BranchCheckoutViewTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Refs; +using SIL.Harmony.Sample.Models; + +namespace SIL.Harmony.Tests.Refs; + +public class BranchCheckoutViewTests : DataModelTestBase +{ + private readonly RefsDataModel _refs; + + public BranchCheckoutViewTests() : base(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }) + { + _refs = _services.GetRequiredService(); + } + + [Fact] + public async Task BranchCheckoutShowsMainPlusBranchCommits() + { + var branchId = Guid.NewGuid(); + var wordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature"); + await DataModel.AddChange(_localClientId, SetWord(wordId, "main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + await _refs.CheckoutBranch(branchId); + await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch")); + + (await DataModel.GetLatest(wordId))!.Text.Should().Be("on-branch"); + + await _refs.CheckoutMain(); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("main"); + } + + [Fact] + public async Task OtherBranchCommitsStayInvisible() + { + var featureId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var featureWordId = Guid.NewGuid(); + var otherWordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, featureId, "feature"); + await _refs.CreateBranch(_localClientId, otherId, "other"); + + await _refs.CheckoutBranch(featureId); + await DataModel.AddChange(_localClientId, SetWord(featureWordId, "feature-word")); + + await _refs.CheckoutBranch(otherId); + await DataModel.AddChange(_localClientId, SetWord(otherWordId, "other-word")); + + await _refs.CheckoutBranch(featureId); + (await DataModel.GetLatest(featureWordId))!.Text.Should().Be("feature-word"); + (await DataModel.GetLatest(otherWordId)).Should().BeNull(); + } + + [Fact] + public async Task BranchAndMainCommitsInterleaveByAuthorTime() + { + var branchId = Guid.NewGuid(); + var earlyId = Guid.NewGuid(); + var lateId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature"); + + // t1 main + await DataModel.AddChange(_localClientId, SetWord(earlyId, "early-main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + await _refs.CheckoutBranch(branchId); + // t2 branch (between main commits chronologically once late main is written) + await DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "mid-branch")); + await _refs.CheckoutMain(); + // t3 main + await DataModel.AddChange(_localClientId, SetWord(lateId, "late-main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + await _refs.CheckoutBranch(branchId); + var words = DataModel.QueryLatest() + .ToBlockingEnumerable(TestContext.Current.CancellationToken) + .Select(w => w.Text) + .OrderBy(t => t) + .ToArray(); + + // All three domain commits visible on branch; main-only would miss mid-branch + words.Should().BeEquivalentTo(["early-main", "late-main", "mid-branch"]); + + await _refs.CheckoutMain(); + var mainWords = DataModel.QueryLatest() + .ToBlockingEnumerable(TestContext.Current.CancellationToken) + .Select(w => w.Text) + .OrderBy(t => t) + .ToArray(); + mainWords.Should().BeEquivalentTo(["early-main", "late-main"]); + } +} diff --git a/src/SIL.Harmony.Tests/Refs/CreateBranchTests.cs b/src/SIL.Harmony.Tests/Refs/CreateBranchTests.cs new file mode 100644 index 0000000..c5b7fd2 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/CreateBranchTests.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Refs; +using SIL.Harmony.Refs.Entities; + +namespace SIL.Harmony.Tests.Refs; + +public class CreateBranchTests : DataModelTestBase +{ + private readonly RefsDataModel _refs; + + public CreateBranchTests() : base(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }) + { + _refs = _services.GetRequiredService(); + } + + [Fact] + public async Task CanCreateAndReadBranch() + { + var branchId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature-x"); + + var branch = await DataModel.GetLatest(branchId); + branch.Should().NotBeNull(); + branch!.Id.Should().Be(branchId); + branch.Name.Should().Be("feature-x"); + } + + [Fact] + public async Task DuplicateBranchNamesAreAllowed() + { + var firstId = Guid.NewGuid(); + var secondId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, firstId, "dup"); + await _refs.CreateBranch(_localClientId, secondId, "dup"); + + var first = await DataModel.GetLatest(firstId); + var second = await DataModel.GetLatest(secondId); + first!.Name.Should().Be("dup"); + second!.Name.Should().Be("dup"); + first.Id.Should().NotBe(second.Id); + + var both = _refs.ListBranches() + .ToBlockingEnumerable(TestContext.Current.CancellationToken) + .ToArray(); + both.Should().HaveCount(2); + } +} diff --git a/src/SIL.Harmony.Tests/Refs/CreateTagTests.cs b/src/SIL.Harmony.Tests/Refs/CreateTagTests.cs new file mode 100644 index 0000000..5cac3d6 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/CreateTagTests.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Refs; +using SIL.Harmony.Refs.Entities; + +namespace SIL.Harmony.Tests.Refs; + +public class CreateTagTests : DataModelTestBase +{ + private readonly RefsDataModel _refs; + + public CreateTagTests() : base(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }) + { + _refs = _services.GetRequiredService(); + } + + [Fact] + public async Task CanCreateAndReadTag() + { + var wordId = Guid.NewGuid(); + var tip = await DataModel.AddChange(_localClientId, SetWord(wordId, "at-tip")); + var tagId = Guid.NewGuid(); + + await _refs.CreateTag(_localClientId, tagId, "release", tip.Id); + + var tag = await DataModel.GetLatest(tagId); + tag.Should().NotBeNull(); + tag!.Id.Should().Be(tagId); + tag.Name.Should().Be("release"); + tag.TargetCommitId.Should().Be(tip.Id); + } + + [Fact] + public async Task DuplicateTagNamesAreAllowed() + { + var tip = await DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "x")); + var firstId = Guid.NewGuid(); + var secondId = Guid.NewGuid(); + await _refs.CreateTag(_localClientId, firstId, "dup", tip.Id); + await _refs.CreateTag(_localClientId, secondId, "dup", tip.Id); + + var both = _refs.ListTags() + .ToBlockingEnumerable(TestContext.Current.CancellationToken) + .ToArray(); + both.Should().HaveCount(2); + both.Select(t => t.Name).Should().AllBe("dup"); + } +} diff --git a/src/SIL.Harmony.Tests/Refs/MergeBranchTests.cs b/src/SIL.Harmony.Tests/Refs/MergeBranchTests.cs new file mode 100644 index 0000000..2a23d08 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/MergeBranchTests.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Changes; +using SIL.Harmony.Refs; +using SIL.Harmony.Refs.Changes; +using SIL.Harmony.Refs.Entities; +using SIL.Harmony.Sample.Models; + +namespace SIL.Harmony.Tests.Refs; + +public class MergeBranchTests : DataModelTestBase +{ + private readonly RefsDataModel _refs; + + public MergeBranchTests() : base(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }) + { + _refs = _services.GetRequiredService(); + } + + [Fact] + public async Task MergeInterleavesBranchCommitsIntoMainByAuthorTime() + { + var branchId = Guid.NewGuid(); + var wordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature"); + + // main: 1, 2, 3 — branch: A, B, C interleaved by time on the same entity + await AddAt(_localClientId, NextDate(), SetWord(wordId, "1")); + await _refs.CheckoutBranch(branchId); + await AddAt(_localClientId, NextDate(), SetWord(wordId, "A"), BranchMeta(branchId)); + await _refs.CheckoutMain(); + await AddAt(_localClientId, NextDate(), SetWord(wordId, "2")); + await _refs.CheckoutBranch(branchId); + await AddAt(_localClientId, NextDate(), SetWord(wordId, "B"), BranchMeta(branchId)); + await _refs.CheckoutMain(); + await AddAt(_localClientId, NextDate(), SetWord(wordId, "3")); + await _refs.CheckoutBranch(branchId); + await AddAt(_localClientId, NextDate(), SetWord(wordId, "C"), BranchMeta(branchId)); + + await _refs.CheckoutMain(); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("3"); + + var merge = await _refs.MergeBranch(_localClientId, branchId); + RefMetadata.GetBranchId(merge.Metadata).Should().BeNull(); + merge.ChangeEntities.Should().ContainSingle(ce => ce.Change is MergeBranchChange); + + (await DataModel.GetLatest(wordId))!.Text.Should().Be("C"); + + var branchScoped = await DbContext.Commits.AsNoTracking() + .ToListAsync(TestContext.Current.CancellationToken); + branchScoped.Where(c => RefMetadata.GetBranchId(c.Metadata) == branchId) + .Should().HaveCount(3); + + var deleted = await DataModel.GetLatest(branchId); + deleted!.DeletedAt.Should().NotBeNull(); + DataModel.QueryLatest().ToBlockingEnumerable(TestContext.Current.CancellationToken) + .Should().BeEmpty(); + } + + [Fact] + public async Task MergeExpandsReplayFromEarliestBranchCommit() + { + var branchId = Guid.NewGuid(); + var earlyId = Guid.NewGuid(); + var lateId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature"); + + await _refs.CheckoutBranch(branchId); + var earlyBranch = await AddAt(_localClientId, NextDate(), SetWord(earlyId, "early-branch"), + BranchMeta(branchId)); + await _refs.CheckoutMain(); + await AddAt(_localClientId, NextDate(), SetWord(lateId, "late-main")); + + (await DataModel.GetLatest(earlyId)).Should().BeNull(); + (await DataModel.GetLatest(lateId))!.Text.Should().Be("late-main"); + + await _refs.MergeBranch(_localClientId, branchId); + + (await DataModel.GetLatest(earlyId))!.Text.Should().Be("early-branch"); + (await DataModel.GetLatest(lateId))!.Text.Should().Be("late-main"); + + var storedEarly = await DbContext.Commits.AsNoTracking() + .SingleAsync(c => c.Id == earlyBranch.Id, TestContext.Current.CancellationToken); + RefMetadata.GetBranchId(storedEarly.Metadata).Should().Be(branchId); + } + + private static CommitMetadata BranchMeta(Guid branchId) + { + var metadata = new CommitMetadata(); + RefMetadata.SetBranchId(metadata, branchId); + return metadata; + } + + private async Task AddAt( + Guid clientId, + DateTimeOffset dateTime, + IChange change, + CommitMetadata? commitMetadata = null) + { + MockTimeProvider.SetNextDateTime(dateTime); + return await DataModel.AddChange(clientId, change, commitMetadata); + } +} diff --git a/src/SIL.Harmony.Tests/Refs/ScopedAuthoringMainIsolationTests.cs b/src/SIL.Harmony.Tests/Refs/ScopedAuthoringMainIsolationTests.cs new file mode 100644 index 0000000..27654e4 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/ScopedAuthoringMainIsolationTests.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Refs; +using SIL.Harmony.Sample.Models; + +namespace SIL.Harmony.Tests.Refs; + +public class ScopedAuthoringMainIsolationTests : DataModelTestBase +{ + private readonly RefsDataModel _refs; + + public ScopedAuthoringMainIsolationTests() : base(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }) + { + _refs = _services.GetRequiredService(); + } + + [Fact] + public async Task BranchAuthoringIsHiddenOnMainCheckout() + { + var branchId = Guid.NewGuid(); + var wordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature"); + + await _refs.CheckoutBranch(branchId); + await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch")); + + await _refs.CheckoutMain(); + var word = await DataModel.GetLatest(wordId); + word.Should().BeNull(); + DataModel.QueryLatest().ToBlockingEnumerable(TestContext.Current.CancellationToken) + .Should().BeEmpty(); + } + + [Fact] + public async Task BranchAuthoringSetsImmutableBranchMetadata() + { + var branchId = Guid.NewGuid(); + var wordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature"); + + await _refs.CheckoutBranch(branchId); + var commit = await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch")); + + RefMetadata.GetBranchId(commit.Metadata).Should().Be(branchId); + + // later commits do not rewrite earlier assignment + await DataModel.AddChange(_localClientId, SetWord(wordId, "again")); + var stored = await DbContext.Commits.AsNoTracking().SingleAsync(c => c.Id == commit.Id, TestContext.Current.CancellationToken); + RefMetadata.GetBranchId(stored.Metadata).Should().Be(branchId); + } + + [Fact] + public async Task AuthoringOverrideCanForceMainOrAnotherBranch() + { + var featureId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var mainWordId = Guid.NewGuid(); + var otherWordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, featureId, "feature"); + await _refs.CreateBranch(_localClientId, otherId, "other"); + + await _refs.CheckoutBranch(featureId); + + var mainCommit = await DataModel.AddChange( + _localClientId, + SetWord(mainWordId, "forced-main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + RefMetadata.GetBranchId(mainCommit.Metadata).Should().BeNull(); + (await DataModel.GetLatest(mainWordId))!.Text.Should().Be("forced-main"); + + var otherCommit = await DataModel.AddChange( + _localClientId, + SetWord(otherWordId, "other-branch"), + RefMetadata.SetAssignment(new(), BranchAssignment.ToBranch(otherId))); + RefMetadata.GetBranchId(otherCommit.Metadata).Should().Be(otherId); + (await DataModel.GetLatest(otherWordId)).Should().BeNull(); + } +} diff --git a/src/SIL.Harmony.Tests/Refs/TagCheckoutTests.cs b/src/SIL.Harmony.Tests/Refs/TagCheckoutTests.cs new file mode 100644 index 0000000..e9f4273 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/TagCheckoutTests.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Refs; +using SIL.Harmony.Sample.Models; +using RefTag = SIL.Harmony.Refs.Entities.Tag; + +namespace SIL.Harmony.Tests.Refs; + +public class TagCheckoutTests : DataModelTestBase +{ + private readonly RefsDataModel _refs; + + public TagCheckoutTests() : base(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }) + { + _refs = _services.GetRequiredService(); + } + + [Fact] + public async Task TagCheckoutShowsStateAsOfTip() + { + var wordId = Guid.NewGuid(); + var tip = await DataModel.AddChange(_localClientId, SetWord(wordId, "at-tag")); + var tagId = Guid.NewGuid(); + await _refs.CreateTag(_localClientId, tagId, "v1", tip.Id); + await DataModel.AddChange(_localClientId, SetWord(wordId, "after-tag")); + + (await DataModel.GetLatest(wordId))!.Text.Should().Be("after-tag"); + + await _refs.CheckoutTag(tagId); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("at-tag"); + + await _refs.CheckoutMain(); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("after-tag"); + } + + [Fact] + public async Task AuthoringOnTagCheckoutIsRejectedByDefault() + { + var tip = await DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "x")); + var tagId = Guid.NewGuid(); + await _refs.CreateTag(_localClientId, tagId, "v1", tip.Id); + await _refs.CheckoutTag(tagId); + + var act = () => DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "nope")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task MoveTagWhileCheckedOutRematerializes() + { + var wordId = Guid.NewGuid(); + var first = await DataModel.AddChange(_localClientId, SetWord(wordId, "first")); + var second = await DataModel.AddChange(_localClientId, SetWord(wordId, "second")); + var tagId = Guid.NewGuid(); + await _refs.CreateTag(_localClientId, tagId, "moving", first.Id); + + await _refs.CheckoutTag(tagId); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("first"); + + await _refs.MoveTag(_localClientId, tagId, second.Id); + + (await DataModel.GetLatest(wordId))!.Text.Should().Be("second"); + (await DataModel.GetLatest(tagId))!.TargetCommitId.Should().Be(second.Id); + _refs.Checkout.Should().BeOfType().Which.TagId.Should().Be(tagId); + } + + [Fact] + public async Task AuthoringOnTagCheckoutWritesToMainWhenConfigured() + { + // Fresh model with AllowAuthoringOnTagToMain enabled via CrdtConfig. + await using var withConfig = new DataModelTestBase(configure: services => + { + services.Configure(config => + { + config.AddHarmonyRefs(); + config.AllowAuthoringOnTagToMain = true; + }); + services.AddHarmonyRefsDataModel(); + }); + var refs = withConfig.GetRequiredService(); + refs.AllowAuthoringOnTagToMain.Should().BeTrue(); + + var tip = await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(Guid.NewGuid(), "at-tag")); + var tagId = Guid.NewGuid(); + await refs.CreateTag(withConfig.LocalClientId, tagId, "v1", tip.Id); + await refs.CheckoutTag(tagId); + + var newWordId = Guid.NewGuid(); + await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(newWordId, "authored-to-main")); + + // The change was authored to main (no branch id), so it is visible on the main checkout. + await refs.CheckoutMain(); + (await withConfig.DataModel.GetLatest(newWordId))!.Text.Should().Be("authored-to-main"); + } + + [Fact] + public async Task TagOnBranchTipShowsBranchViewAsOfTip() + { + var branchId = Guid.NewGuid(); + var wordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature"); + await DataModel.AddChange(_localClientId, SetWord(wordId, "main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + await _refs.CheckoutBranch(branchId); + var branchTip = await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch")); + + var tagId = Guid.NewGuid(); + await _refs.CheckoutMain(); + await _refs.CreateTag(_localClientId, tagId, "wip", branchTip.Id); + await DataModel.AddChange(_localClientId, SetWord(wordId, "later-main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + await _refs.CheckoutTag(tagId); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("on-branch"); + } +} diff --git a/src/SIL.Harmony.Tests/Refs/TagRollForwardListenerTests.cs b/src/SIL.Harmony.Tests/Refs/TagRollForwardListenerTests.cs new file mode 100644 index 0000000..39d49e0 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/TagRollForwardListenerTests.cs @@ -0,0 +1,190 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Refs; +using SIL.Harmony.Refs.Changes; +using SIL.Harmony.Sample.Models; + +namespace SIL.Harmony.Tests.Refs; + +/// +/// Ticket 05: with refs registered, an active tag checkout rolls forward automatically after any +/// apply — through for a synced tag move and through +/// for a local one — driven by the post-apply listener rather than +/// a wrapper call. Roll-forward is skipped when the tip did not move. +/// +public class TagRollForwardListenerTests : IAsyncLifetime +{ + private readonly DataModelTestBase _client1; + private readonly DataModelTestBase _client2; + private readonly RefsDataModel _refs1; + private readonly RefsDataModel _refs2; + + public TagRollForwardListenerTests() + { + static void Configure(IServiceCollection services) + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + } + + _client1 = new DataModelTestBase(configure: Configure); + _client2 = new DataModelTestBase(configure: Configure); + _refs1 = _client1.GetRequiredService(); + _refs2 = _client2.GetRequiredService(); + } + + public async ValueTask InitializeAsync() + { + await _client1.InitializeAsync(); + await _client2.InitializeAsync(); + } + + public async ValueTask DisposeAsync() + { + await _client1.DisposeAsync(); + await _client2.DisposeAsync(); + } + + [Fact] + public async Task SyncedTagMoveRollsForwardActiveCheckout_ViaDataModelSyncWith() + { + var wordId = Guid.NewGuid(); + var first = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "first")); + var second = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "second")); + var tagId = Guid.NewGuid(); + await _refs1.CreateTag(_client1.LocalClientId, tagId, "release", first.Id); + + await _client1.DataModel.SyncWith(_client2.DataModel); + + await _refs2.CheckoutTag(tagId); + (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("first"); + + // Move the tag on client1, then sync through the plain DataModel API — the listener, not a + // RefsDataModel.SyncWith wrapper, must roll client2's active tag view forward. + await _refs1.MoveTag(_client1.LocalClientId, tagId, second.Id); + await _client2.DataModel.SyncWith(_client1.DataModel); + + (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("second"); + } + + [Fact] + public async Task LocalTagMoveRollsForwardActiveCheckout_ViaListener() + { + var wordId = Guid.NewGuid(); + var first = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "first")); + var second = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "second")); + var tagId = Guid.NewGuid(); + await _refs1.CreateTag(_client1.LocalClientId, tagId, "release", first.Id); + + await _refs1.CheckoutTag(tagId); + (await _client1.DataModel.GetLatest(wordId))!.Text.Should().Be("first"); + + // Author the move straight through DataModel (explicit main assignment, so it is allowed on a + // tag checkout and lands on main). Only the post-apply listener can roll the view forward here. + await _client1.DataModel.AddChange( + _client1.LocalClientId, + new MoveTagChange(tagId, second.Id), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + (await _client1.DataModel.GetLatest(wordId))!.Text.Should().Be("second"); + } + + [Fact(Timeout = 30_000)] + public async Task LocalTagMoveRollsForwardOnPersistentDatabase() + { + // A file-backed database uses a stable, shared apply lock (unlike :memory:, which normalizes + // to a fresh lock per repository). The lock is not reentrant, so if the post-apply + // notification fired while the apply lock was held, the listener's roll-forward would deadlock + // trying to take that same lock. This exercises that path; a regression would hang and trip + // the timeout. + var dbPath = Path.Combine(Path.GetTempPath(), $"harmony-rollforward-{Guid.NewGuid():N}.db"); + var connection = new SqliteConnection($"Data Source={dbPath}"); + var client = new DataModelTestBase(connection, configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }); + try + { + var refs = client.GetRequiredService(); + + var wordId = Guid.NewGuid(); + var first = await client.DataModel.AddChange(client.LocalClientId, client.SetWord(wordId, "first")); + var second = await client.DataModel.AddChange(client.LocalClientId, client.SetWord(wordId, "second")); + var tagId = Guid.NewGuid(); + await refs.CreateTag(client.LocalClientId, tagId, "release", first.Id); + + await refs.CheckoutTag(tagId); + (await client.DataModel.GetLatest(wordId))!.Text.Should().Be("first"); + + await client.DataModel.AddChange( + client.LocalClientId, + new MoveTagChange(tagId, second.Id), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + (await client.DataModel.GetLatest(wordId))!.Text.Should().Be("second"); + } + finally + { + await client.DisposeAsync(); + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) File.Delete(dbPath); + } + } + + [Fact] + public async Task NoRollForwardWhenActiveTagTipDidNotMove() + { + var wordId = Guid.NewGuid(); + var laterId = Guid.NewGuid(); + var first = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "first")); + var tagId = Guid.NewGuid(); + await _refs1.CreateTag(_client1.LocalClientId, tagId, "release", first.Id); + + await _refs1.CheckoutTag(tagId); + (await _client1.DataModel.GetLatest(wordId))!.Text.Should().Be("first"); + + // A main-line commit that does not touch the tag: the tip is unchanged, so the view stays + // pinned at the tag tip and the new commit remains invisible in the tag view. + await _client1.DataModel.AddChange( + _client1.LocalClientId, + _client1.SetWord(laterId, "after-tip"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + (await _client1.DataModel.GetLatest(wordId))!.Text.Should().Be("first"); + (await _client1.DataModel.GetLatest(laterId)).Should().BeNull(); + } +} + +/// +/// Regression: with refs not registered, 's apply paths (local author + sync) +/// behave exactly as before — no listener runs. +/// +public class CoreOnlyApplyTests : IAsyncLifetime +{ + private readonly DataModelTestBase _client1 = new(); + private readonly DataModelTestBase _client2 = new(); + + public async ValueTask InitializeAsync() + { + await _client1.InitializeAsync(); + await _client2.InitializeAsync(); + } + + public async ValueTask DisposeAsync() + { + await _client1.DisposeAsync(); + await _client2.DisposeAsync(); + } + + [Fact] + public async Task AuthorAndSyncBehaveAsBeforeWithoutRefs() + { + var wordId = Guid.NewGuid(); + await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "hello")); + + await _client1.DataModel.SyncWith(_client2.DataModel); + + (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("hello"); + } +} diff --git a/src/SIL.Harmony.Tests/Refs/TagSyncRollForwardTests.cs b/src/SIL.Harmony.Tests/Refs/TagSyncRollForwardTests.cs new file mode 100644 index 0000000..bcf7a62 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/TagSyncRollForwardTests.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Refs; +using SIL.Harmony.Sample.Models; + +namespace SIL.Harmony.Tests.Refs; + +public class TagSyncRollForwardTests : IAsyncLifetime +{ + private readonly DataModelTestBase _client1; + private readonly DataModelTestBase _client2; + private readonly RefsDataModel _refs1; + private readonly RefsDataModel _refs2; + + public TagSyncRollForwardTests() + { + static void Configure(IServiceCollection services) + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + } + + _client1 = new DataModelTestBase(configure: Configure); + _client2 = new DataModelTestBase(configure: Configure); + _refs1 = _client1.GetRequiredService(); + _refs2 = _client2.GetRequiredService(); + } + + public async ValueTask InitializeAsync() + { + await _client1.InitializeAsync(); + await _client2.InitializeAsync(); + } + + public async ValueTask DisposeAsync() + { + await _client1.DisposeAsync(); + await _client2.DisposeAsync(); + } + + [Fact] + public async Task SyncMoveTagRollsForwardActiveCheckout() + { + var wordId = Guid.NewGuid(); + var first = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "first")); + var second = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "second")); + var tagId = Guid.NewGuid(); + await _refs1.CreateTag(_client1.LocalClientId, tagId, "release", first.Id); + + await _client1.DataModel.SyncWith(_client2.DataModel); + + await _refs2.CheckoutTag(tagId); + (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("first"); + + await _refs1.MoveTag(_client1.LocalClientId, tagId, second.Id); + await _client2.DataModel.SyncWith(_client1.DataModel); + + (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("second"); + } +} diff --git a/src/SIL.Harmony.Tests/Refs/TransparentAuthoringTests.cs b/src/SIL.Harmony.Tests/Refs/TransparentAuthoringTests.cs new file mode 100644 index 0000000..a515264 --- /dev/null +++ b/src/SIL.Harmony.Tests/Refs/TransparentAuthoringTests.cs @@ -0,0 +1,210 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SIL.Harmony.Refs; +using SIL.Harmony.Sample.Models; + +namespace SIL.Harmony.Tests.Refs; + +/// +/// Ticket 04: authoring through directly (no RefsDataModel +/// wrapper) transparently applies the current checkout's branch assignment, and a +/// per-call override via takes precedence. +/// +public class TransparentAuthoringTests : DataModelTestBase +{ + private readonly RefsDataModel _refs; + + public TransparentAuthoringTests() : base(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }) + { + _refs = _services.GetRequiredService(); + } + + [Fact] + public async Task DirectAuthoringOnBranchCheckoutStampsBranchAndIsHiddenOnMain() + { + var branchId = Guid.NewGuid(); + var wordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, branchId, "feature"); + + await _refs.CheckoutBranch(branchId); + var commit = await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch")); + + RefMetadata.GetBranchId(commit.Metadata).Should().Be(branchId); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("on-branch"); + + await _refs.CheckoutMain(); + (await DataModel.GetLatest(wordId)).Should().BeNull(); + } + + [Fact] + public async Task DirectAuthoringOnMainCheckoutAuthorsToMain() + { + var wordId = Guid.NewGuid(); + var commit = await DataModel.AddChange(_localClientId, SetWord(wordId, "on-main")); + + RefMetadata.GetBranchId(commit.Metadata).Should().BeNull(); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("on-main"); + } + + [Fact] + public async Task DirectAuthoringOnTagCheckoutThrowsByDefault() + { + var tip = await DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "at-tag")); + var tagId = Guid.NewGuid(); + await _refs.CreateTag(_localClientId, tagId, "v1", tip.Id); + await _refs.CheckoutTag(tagId); + + var act = () => DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "nope")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DirectAuthoringOnTagCheckoutWritesToMainWhenConfigured() + { + await using var withConfig = new DataModelTestBase(configure: services => + { + services.Configure(config => + { + config.AddHarmonyRefs(); + config.AllowAuthoringOnTagToMain = true; + }); + services.AddHarmonyRefsDataModel(); + }); + var refs = withConfig.GetRequiredService(); + + var tip = await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(Guid.NewGuid(), "at-tag")); + var tagId = Guid.NewGuid(); + await refs.CreateTag(withConfig.LocalClientId, tagId, "v1", tip.Id); + await refs.CheckoutTag(tagId); + + var newWordId = Guid.NewGuid(); + var commit = await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(newWordId, "authored-to-main")); + RefMetadata.GetBranchId(commit.Metadata).Should().BeNull(); + + await refs.CheckoutMain(); + (await withConfig.DataModel.GetLatest(newWordId))!.Text.Should().Be("authored-to-main"); + } + + [Fact] + public async Task PerCallOverrideForcesMainOrAnotherBranchRegardlessOfCheckout() + { + var featureId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var mainWordId = Guid.NewGuid(); + var otherWordId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, featureId, "feature"); + await _refs.CreateBranch(_localClientId, otherId, "other"); + + await _refs.CheckoutBranch(featureId); + + var mainCommit = await DataModel.AddChange( + _localClientId, + SetWord(mainWordId, "forced-main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + RefMetadata.GetBranchId(mainCommit.Metadata).Should().BeNull(); + (await DataModel.GetLatest(mainWordId))!.Text.Should().Be("forced-main"); + + var otherCommit = await DataModel.AddChange( + _localClientId, + SetWord(otherWordId, "other-branch"), + RefMetadata.SetAssignment(new(), BranchAssignment.ToBranch(otherId))); + RefMetadata.GetBranchId(otherCommit.Metadata).Should().Be(otherId); + (await DataModel.GetLatest(otherWordId)).Should().BeNull(); + } + + [Fact] + public async Task AllRegisteredInterceptorsAreInvoked() + { + await using var tb = new DataModelTestBase(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + }); + var recording = tb.GetRequiredService(); + var refs = tb.GetRequiredService(); + + var branchId = Guid.NewGuid(); + await refs.CreateBranch(tb.LocalClientId, branchId, "feature"); + await refs.CheckoutBranch(branchId); + var commit = await tb.DataModel.AddChange(tb.LocalClientId, tb.SetWord(Guid.NewGuid(), "x")); + + // The extra interceptor ran alongside the checkout interceptor... + recording.Count.Should().BeGreaterThan(0); + // ...and the checkout interceptor still applied the branch assignment. + RefMetadata.GetBranchId(commit.Metadata).Should().Be(branchId); + } + + [Fact] + public async Task ExplicitAssignmentMarkerIsNotPersisted() + { + var featureId = Guid.NewGuid(); + await _refs.CreateBranch(_localClientId, featureId, "feature"); + await _refs.CheckoutBranch(featureId); + + var wordId = Guid.NewGuid(); + var commit = await DataModel.AddChange( + _localClientId, + SetWord(wordId, "forced-main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + + // Explicit main leaves no branch id and no transient marker behind. + var stored = await DbContext.Commits.AsNoTracking() + .SingleAsync(c => c.Id == commit.Id, TestContext.Current.CancellationToken); + RefMetadata.GetBranchId(stored.Metadata).Should().BeNull(); + stored.Metadata.ExtraMetadata.Should().BeEmpty(); + } + + private sealed class RecordingInterceptor : ICommitInterceptor + { + public int Count { get; private set; } + public void OnCommitAuthored(Commit commit) => Count++; + } + + [Fact] + public async Task PerCallOverrideToMainOnTagCheckoutDoesNotThrow() + { + await using var withConfig = new DataModelTestBase(configure: services => + { + services.Configure(config => config.AddHarmonyRefs()); + services.AddHarmonyRefsDataModel(); + }); + var refs = withConfig.GetRequiredService(); + + var tip = await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(Guid.NewGuid(), "at-tag")); + var tagId = Guid.NewGuid(); + await refs.CreateTag(withConfig.LocalClientId, tagId, "v1", tip.Id); + await refs.CheckoutTag(tagId); + + var wordId = Guid.NewGuid(); + // Explicit main assignment bypasses the tag-authoring rejection even though + // AllowAuthoringOnTagToMain is false, because the caller opted in per-call. + var commit = await withConfig.DataModel.AddChange( + withConfig.LocalClientId, + withConfig.SetWord(wordId, "explicit-main"), + RefMetadata.SetAssignment(new(), BranchAssignment.Main)); + RefMetadata.GetBranchId(commit.Metadata).Should().BeNull(); + } +} + +/// +/// Regression: with refs not registered, authoring through +/// behaves exactly as before — no interceptor, no branch metadata. +/// +public class CoreOnlyAuthoringTests : DataModelTestBase +{ + [Fact] + public async Task AuthoringWithoutRefsAppliesNoBranchMetadata() + { + var wordId = Guid.NewGuid(); + var commit = await DataModel.AddChange(_localClientId, SetWord(wordId, "plain")); + + commit.Metadata.ExtraMetadata.Should().BeEmpty(); + (await DataModel.GetLatest(wordId))!.Text.Should().Be("plain"); + } +} diff --git a/src/SIL.Harmony.Tests/RepositoryTests.cs b/src/SIL.Harmony.Tests/RepositoryTests.cs index 3a697ea..4d50832 100644 --- a/src/SIL.Harmony.Tests/RepositoryTests.cs +++ b/src/SIL.Harmony.Tests/RepositoryTests.cs @@ -242,6 +242,48 @@ await _repository.AddSnapshots([ commit.Id.Should().Be(commitIds[1], $"commit order: [{string.Join(", ", commitIds)}]"); } + [Fact] + public async Task GetEntityIdsForChangeType_ReturnsAllMatchingChangesWhenUnscoped() + { + var commit1 = Commit(Guid.NewGuid(), Time(1, 0)); + var commit2 = Commit(Guid.NewGuid(), Time(2, 0)); + var commit3 = Commit(Guid.NewGuid(), Time(3, 0)); + await _repository.AddCommits([commit1, commit2, commit3]); + + var ids = await _repository.GetEntityIdsForChangeType(); + + ids.Should().BeEquivalentTo(new[] + { + commit1.ChangeEntities[0].EntityId, + commit2.ChangeEntities[0].EntityId, + commit3.ChangeEntities[0].EntityId, + }); + } + + [Fact] + public async Task GetEntityIdsForChangeType_ScopedRepoIgnoresChangesAfterTip() + { + var commit1 = Commit(Guid.NewGuid(), Time(1, 0)); + var commit2 = Commit(Guid.NewGuid(), Time(2, 0)); + var commit3 = Commit(Guid.NewGuid(), Time(3, 0)); + await _repository.AddCommits([commit1, commit2, commit3]); + + var scopedIds = await _repository.GetScopedRepository(commit2) + .GetEntityIdsForChangeType(); + + // commit2 is the tip: at-or-before is included (inclusive), the later commit3 change is not. + scopedIds.Should().BeEquivalentTo(new[] + { + commit1.ChangeEntities[0].EntityId, + commit2.ChangeEntities[0].EntityId, + }); + scopedIds.Should().NotContain(commit3.ChangeEntities[0].EntityId); + + // the unscoped repo still sees the post-tip change. + var allIds = await _repository.GetEntityIdsForChangeType(); + allIds.Should().Contain(commit3.ChangeEntities[0].EntityId); + } + [Fact] public async Task DeleteStaleSnapshots_Works() { diff --git a/src/SIL.Harmony.Tests/SIL.Harmony.Tests.csproj b/src/SIL.Harmony.Tests/SIL.Harmony.Tests.csproj index f542263..3af010d 100644 --- a/src/SIL.Harmony.Tests/SIL.Harmony.Tests.csproj +++ b/src/SIL.Harmony.Tests/SIL.Harmony.Tests.csproj @@ -34,6 +34,7 @@ + diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/CrdtConfig.cs index 2ae0895..dc7ae2d 100644 --- a/src/SIL.Harmony/CrdtConfig.cs +++ b/src/SIL.Harmony/CrdtConfig.cs @@ -21,6 +21,17 @@ public class CrdtConfig /// after adding any commit validate the commit history, not great for performance but good for testing. /// public bool AlwaysValidateCommits { get; set; } = true; + /// + /// Selects which stored commits are applied when materializing snapshots. + /// Default includes every commit (unchanged behaviour). Commits are still persisted and synced regardless. + /// + public ICommitMaterializationFilter CommitMaterializationFilter { get; set; } = + IncludeAllCommitsFilter.Instance; + /// + /// When true, authoring while checked out on a tag writes to main instead of being rejected. + /// Only relevant when using SIL.Harmony.Refs (RefsDataModel). Default false. + /// + public bool AllowAuthoringOnTagToMain { get; set; } public ChangeTypeListBuilder ChangeTypeListBuilder { get; } = new(); public IEnumerable ChangeTypes => ChangeTypeListBuilder.Types.Select(t => t.DerivedType); public ObjectTypeListBuilder ObjectTypeListBuilder { get; } = new(); diff --git a/src/SIL.Harmony/CrdtKernel.cs b/src/SIL.Harmony/CrdtKernel.cs index 2d1c95d..d777aa2 100644 --- a/src/SIL.Harmony/CrdtKernel.cs +++ b/src/SIL.Harmony/CrdtKernel.cs @@ -40,7 +40,10 @@ public static IServiceCollection AddCrdtDataCore(this IServiceCollection service provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService>(), - provider.GetRequiredService>() + provider.GetRequiredService>(), + provider.GetService(), + provider.GetServices(), + () => provider.GetServices() )); //must use factory method because ResourceService constructor is internal services.AddScoped(provider => new ResourceService( diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 8707fcb..5b93edb 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -23,21 +23,36 @@ public class DataModel : ISyncable, IAsyncDisposable private readonly IHybridDateTimeProvider _timeProvider; private readonly IOptions _crdtConfig; private readonly ILogger _logger; + private readonly ICommitMaterializationFilter? _materializationFilter; + private readonly IReadOnlyList _commitInterceptors; + private readonly Func>? _commitAppliedListenersFactory; //constructor must be internal because CrdtRepository is internal internal DataModel(CrdtRepositoryFactory crdtRepositoryFactory, JsonSerializerOptions serializerOptions, IHybridDateTimeProvider timeProvider, IOptions crdtConfig, - ILogger logger) + ILogger logger, + ICommitMaterializationFilter? materializationFilter = null, + IEnumerable? commitInterceptors = null, + Func>? commitAppliedListenersFactory = null) { _crdtRepositoryFactory = crdtRepositoryFactory; _serializerOptions = serializerOptions; _timeProvider = timeProvider; _crdtConfig = crdtConfig; _logger = logger; + _materializationFilter = materializationFilter; + _commitInterceptors = commitInterceptors?.ToArray() ?? []; + // Listeners are resolved lazily (at apply time) rather than in the constructor: a listener's + // roll-forward depends transitively on this DataModel, so eager resolution would form a + // constructor cycle. The interceptor above has no such dependency and is resolved eagerly. + _commitAppliedListenersFactory = commitAppliedListenersFactory; } + private ICommitMaterializationFilter MaterializationFilter => + _materializationFilter ?? _crdtConfig.Value.CommitMaterializationFilter; + /// /// add a change to the model, snapshots will be updated @@ -62,20 +77,27 @@ public async Task AddManyChanges(Guid clientId, Func commitMetadata, int changesPerCommitMax = 100) { - await using var repo = await _crdtRepositoryFactory.CreateRepository(); - var commits = changes - .Chunk(changesPerCommitMax) - .Select(chunk => NewCommit(clientId, commitMetadata(), chunk)) - .ToArray(); - if (commits is []) return; - using var locked = await repo.Lock(); - repo.ClearChangeTracker(); + Commit[] commits; + await using (var repo = await _crdtRepositoryFactory.CreateRepository()) + { + commits = changes + .Chunk(changesPerCommitMax) + .Select(chunk => NewCommit(clientId, commitMetadata(), chunk)) + .ToArray(); + if (commits is []) return; + using var locked = await repo.Lock(); + repo.ClearChangeTracker(); - await using var transaction = await repo.BeginTransactionAsync(); - var updatedCommits = await repo.AddCommits(commits); - await UpdateSnapshots(repo, updatedCommits); - await ValidateCommits(repo); - await transaction.CommitAsync(); + await using var transaction = await repo.BeginTransactionAsync(); + var updatedCommits = await repo.AddCommits(commits); + await UpdateSnapshots(repo, updatedCommits); + await ValidateCommits(repo); + await transaction.CommitAsync(); + } + // Notify after the repo (and its lock) is released: a listener's roll-forward opens its own + // repository, and the apply lock is not reentrant, so notifying while holding it would deadlock + // on a persistent database. + await NotifyCommitsApplied(commits); } /// @@ -98,24 +120,49 @@ private Commit NewCommit(Guid clientId, CommitMetadata? commitMetadata, IEnumera Metadata = commitMetadata ?? new() }; commit.ChangeEntities.AddRange(changes.Select((c, i) => ToChangeEntity(c, i, commit.Id))); + // Single author choke point: let any registered interceptors (e.g. refs branch assignment) + // stamp metadata or reject authoring before the commit is persisted. Metadata is not part + // of the commit hash, so this is safe to do here. Sync-applied commits never pass through + // NewCommit, so their assignment is left untouched. + foreach (var interceptor in _commitInterceptors) + interceptor.OnCommitAuthored(commit); return commit; } private async Task Add(Commit commit) { - await using var repo = await _crdtRepositoryFactory.CreateRepository(); - using var locked = await repo.Lock(); - if (await repo.HasCommit(commit.Id)) return; - repo.ClearChangeTracker(); + await using (var repo = await _crdtRepositoryFactory.CreateRepository()) + { + using var locked = await repo.Lock(); + if (await repo.HasCommit(commit.Id)) return; + repo.ClearChangeTracker(); - await using var transaction = repo.IsInTransaction ? null : await repo.BeginTransactionAsync(); - var updatedCommits = await repo.AddCommit(commit); - await UpdateSnapshots(repo, updatedCommits); + await using var transaction = repo.IsInTransaction ? null : await repo.BeginTransactionAsync(); + var updatedCommits = await repo.AddCommit(commit); + await UpdateSnapshots(repo, updatedCommits); - if (AlwaysValidate) await ValidateCommits(repo); + if (AlwaysValidate) await ValidateCommits(repo); + // Nested apply: the caller owns the transaction and the post-apply notification, so a + // listener never observes uncommitted state. Bail before notifying. + if (transaction is null) return; + await transaction.CommitAsync(); + } + // See NotifyCommitsApplied / AddManyChanges: fire only after the lock is released. + await NotifyCommitsApplied([commit]); + } - if (transaction is not null) await transaction.CommitAsync(); + /// + /// Fires registered s after an apply transaction commits and + /// the repository lock is released. Only invoked from the genuine apply entrypoints (author + + /// sync) — never from — so a listener that rematerializes + /// cannot re-enter. + /// + private async Task NotifyCommitsApplied(IReadOnlyCollection commits) + { + if (_commitAppliedListenersFactory is null || commits.Count == 0) return; + foreach (var listener in _commitAppliedListenersFactory()) + await listener.OnCommitsAppliedAsync(commits); } public ValueTask DisposeAsync() @@ -136,19 +183,25 @@ async Task ISyncable.AddRangeFromSync(IEnumerable commits) commits = commits.ToArray(); try { - await using var repo = await _crdtRepositoryFactory.CreateRepository(); - using var locked = await repo.Lock(); - repo.ClearChangeTracker(); - _timeProvider.TakeLatestTime(commits.Select(c => c.HybridDateTime)); - var (oldestChange, newCommits) = await repo.FilterExistingCommits(commits.ToArray()); - //no changes added - if (oldestChange is null || newCommits is []) return; - - await using var transaction = await repo.BeginTransactionAsync(); - var updatedCommits = await repo.AddCommits(newCommits); - await UpdateSnapshots(repo, updatedCommits); - await ValidateCommits(repo); - await transaction.CommitAsync(); + Commit[] newCommits; + await using (var repo = await _crdtRepositoryFactory.CreateRepository()) + { + using var locked = await repo.Lock(); + repo.ClearChangeTracker(); + _timeProvider.TakeLatestTime(commits.Select(c => c.HybridDateTime)); + Commit? oldestChange; + (oldestChange, newCommits) = await repo.FilterExistingCommits(commits.ToArray()); + //no changes added + if (oldestChange is null || newCommits is []) return; + + await using var transaction = await repo.BeginTransactionAsync(); + var updatedCommits = await repo.AddCommits(newCommits); + await UpdateSnapshots(repo, updatedCommits); + await ValidateCommits(repo); + await transaction.CommitAsync(); + } + // Fire after the lock is released so a listener's roll-forward can take it (see NotifyCommitsApplied). + await NotifyCommitsApplied(newCommits); } catch (DbUpdateException e) { @@ -186,13 +239,25 @@ ValueTask ISyncable.ShouldSync() private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commitsToApply) { if (commitsToApply.Count == 0) return; - var oldestAddedCommit = commitsToApply.First(); - await repo.DeleteStaleSnapshots(oldestAddedCommit); + var filter = MaterializationFilter; + if (filter is IMaterializationApplyWindow applyWindow) + commitsToApply = await applyWindow.PrepareApplyWindowAsync(repo, commitsToApply); + + var commitsToMaterialize = commitsToApply + .Where(filter.Include) + .ToSortedSet(); + if (commitsToMaterialize.Count == 0) return; + + // Stale boundary is the unfiltered apply-window head. If that head is excluded, + // DeleteStaleSnapshots(filtered.First()) would not clear already-materialized later + // commits (WhereAfter is strict), and re-applying them would duplicate snapshots. + var oldestInApplyWindow = commitsToApply.First(); + await repo.DeleteStaleSnapshots(oldestInApplyWindow); Dictionary snapshotLookup = []; - if (commitsToApply.Count > 10) + if (commitsToMaterialize.Count > 10) { // Bulk-load relevant snapshots to minimize DB queries - var entityIds = commitsToApply + var entityIds = commitsToMaterialize .SelectMany(c => c.ChangeEntities.Select(ce => ce.EntityId)) .Distinct(); @@ -204,7 +269,7 @@ private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commit } var snapshotWorker = new SnapshotWorker(snapshotLookup, repo, _crdtConfig.Value); - await snapshotWorker.UpdateSnapshots(commitsToApply); + await snapshotWorker.UpdateSnapshots(commitsToMaterialize); } private async Task ValidateCommits(CrdtRepository repo) @@ -227,6 +292,12 @@ private async Task ValidateCommits(CrdtRepository repo) } } + public async Task GetCommit(Guid commitId) + { + await using var repo = await _crdtRepositoryFactory.CreateRepository(); + return await repo.CurrentCommits().AsNoTracking().SingleAsync(c => c.Id == commitId); + } + public async Task RegenerateSnapshots() { await using var repo = await _crdtRepositoryFactory.CreateRepository(); diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs index 6312044..2ebba7c 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Nito.AsyncEx; using SIL.Harmony.Changes; +using SIL.Harmony.Entities; using SIL.Harmony.Resource; namespace SIL.Harmony.Db; @@ -51,12 +52,14 @@ internal class CrdtRepository : IDisposable, IAsyncDisposable private readonly ICrdtDbContext _dbContext; private readonly IOptions _crdtConfig; private readonly ILogger _logger; + private readonly Commit? _ignoreChangesAfter; public CrdtRepository(ICrdtDbContext dbContext, IOptions crdtConfig, ILogger logger, Commit? ignoreChangesAfter = null) { _crdtConfig = crdtConfig; + _ignoreChangesAfter = ignoreChangesAfter; _dbContext = ignoreChangesAfter is not null ? new ScopedDbContext(dbContext, ignoreChangesAfter) : dbContext; _logger = logger; //we can't use the scoped db context is it prevents access to the DbSet for the Snapshots, @@ -247,6 +250,64 @@ public async Task GetCommitsAfter(Commit? commit) .ToArrayAsync(); } + /// + /// Distinct entity ids from ChangeEntities whose JSON change has the given polymorphic type name. + /// + public async Task GetEntityIdsForChangeType(string changeTypeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(changeTypeName); + // Discriminator key is "$type"; SQLite requires a quoted JSON path for the leading $. + var typePath = $"$.\"{CrdtConstants.ChangeDiscriminatorProperty}\""; + // When scoped (as-of / tag tip), only count changes on commits at or before the tip, matching the + // before/at-tip ordering ScopedDbContext applies to Commits/Snapshots (WhereBefore inclusive). + // A null tip disables the bound, so this one query serves both scoped and unscoped repos. + var ignoreAfterDate = _ignoreChangesAfter?.HybridDateTime.DateTime.UtcDateTime; + var ignoreAfterCounter = _ignoreChangesAfter?.HybridDateTime.Counter; + var ignoreAfterCommitId = _ignoreChangesAfter?.Id; + return await _dbContext.Database + .SqlQuery( + $""" + SELECT DISTINCT "ce"."EntityId" AS "Value" + FROM "ChangeEntities" AS "ce" + INNER JOIN "Commits" AS "c" ON "ce"."CommitId" = "c"."Id" + WHERE "ce"."Change" ->> {typePath} = {changeTypeName} + AND ({ignoreAfterDate} IS NULL + OR "c"."DateTime" < {ignoreAfterDate} + OR ("c"."DateTime" = {ignoreAfterDate} AND "c"."Counter" < {ignoreAfterCounter}) + OR ("c"."DateTime" = {ignoreAfterDate} AND "c"."Counter" = {ignoreAfterCounter} AND "c"."Id" < {ignoreAfterCommitId}) + OR "c"."Id" = {ignoreAfterCommitId}) + """) + .ToArrayAsync(); + } + + /// + /// Distinct entity ids from ChangeEntities for the given change type's . + /// + public Task GetEntityIdsForChangeType() where TChange : IPolyType => + GetEntityIdsForChangeType(TChange.TypeName); + + /// + /// Distinct entity ids from ChangeEntities for a change type that implements . + /// + public Task GetEntityIdsForChangeType(Type changeType) + { + ArgumentNullException.ThrowIfNull(changeType); + if (!typeof(IPolyType).IsAssignableFrom(changeType)) + { + throw new ArgumentException($"Change type {changeType} must implement {nameof(IPolyType)}.", nameof(changeType)); + } + + var typeName = changeType + .GetProperty(nameof(IPolyType.TypeName), BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + ?.GetValue(null) as string; + if (string.IsNullOrWhiteSpace(typeName)) + { + throw new ArgumentException($"Change type {changeType} does not expose a static {nameof(IPolyType.TypeName)}.", nameof(changeType)); + } + + return GetEntityIdsForChangeType(typeName); + } + public async Task FindSnapshot(Guid id, bool tracking = false) { return await Snapshots diff --git a/src/SIL.Harmony/ICommitAppliedListener.cs b/src/SIL.Harmony/ICommitAppliedListener.cs new file mode 100644 index 0000000..6ef8db4 --- /dev/null +++ b/src/SIL.Harmony/ICommitAppliedListener.cs @@ -0,0 +1,13 @@ +namespace SIL.Harmony; + +/// +/// Optional hook invoked after commits are applied — for both local authoring and sync — once the +/// apply transaction has committed. The method is asynchronous so implementations can do database +/// work (e.g. rolling a tag checkout forward). It is not invoked from +/// or scoped read paths; this entrypoint boundary is what prevents a listener that rematerializes +/// from re-entering. +/// +public interface ICommitAppliedListener +{ + Task OnCommitsAppliedAsync(IReadOnlyCollection commits); +} diff --git a/src/SIL.Harmony/ICommitInterceptor.cs b/src/SIL.Harmony/ICommitInterceptor.cs new file mode 100644 index 0000000..d2e3298 --- /dev/null +++ b/src/SIL.Harmony/ICommitInterceptor.cs @@ -0,0 +1,11 @@ +namespace SIL.Harmony; + +/// +/// Optional hook invoked once per locally-authored commit, before it is persisted. +/// Implementations may mutate and may throw to reject authoring. +/// It is not invoked for commits received via sync — those keep the assignment their author gave them. +/// +public interface ICommitInterceptor +{ + void OnCommitAuthored(Commit commit); +} diff --git a/src/SIL.Harmony/ICommitMaterializationFilter.cs b/src/SIL.Harmony/ICommitMaterializationFilter.cs new file mode 100644 index 0000000..e3aca6c --- /dev/null +++ b/src/SIL.Harmony/ICommitMaterializationFilter.cs @@ -0,0 +1,17 @@ +namespace SIL.Harmony; + +/// +/// Decides which commits are applied when materializing snapshots. +/// Commits are still stored and synced regardless of this filter. +/// +public interface ICommitMaterializationFilter +{ + bool Include(Commit commit); +} + +public sealed class IncludeAllCommitsFilter : ICommitMaterializationFilter +{ + public static IncludeAllCommitsFilter Instance { get; } = new(); + + public bool Include(Commit commit) => true; +} diff --git a/src/SIL.Harmony/IMaterializationApplyWindow.cs b/src/SIL.Harmony/IMaterializationApplyWindow.cs new file mode 100644 index 0000000..ab97ebb --- /dev/null +++ b/src/SIL.Harmony/IMaterializationApplyWindow.cs @@ -0,0 +1,12 @@ +using SIL.Harmony.Db; + +namespace SIL.Harmony; + +/// +/// Optional materialization filter hook to expand the commit apply window +/// (e.g. when a merge makes earlier branch-scoped commits newly visible). +/// +internal interface IMaterializationApplyWindow +{ + Task> PrepareApplyWindowAsync(CrdtRepository repo, SortedSet commitsToApply); +} diff --git a/src/SIL.Harmony/SIL.Harmony.csproj b/src/SIL.Harmony/SIL.Harmony.csproj index 094fe40..95fbd63 100644 --- a/src/SIL.Harmony/SIL.Harmony.csproj +++ b/src/SIL.Harmony/SIL.Harmony.csproj @@ -8,6 +8,7 @@ +