Public Access
Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater, and the two collided in three places. Every one of them resolves the same way: main got there first, so this branch moves. **Two ADRs were both numbered 0012.** Main's is realtime push; this one is now [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call this a conflict — the filenames differ — so it would have merged quietly and left the directory with two 0012s and every cross-reference ambiguous. Renumbered here along with the nine places that point at it. **Two manual-check phases were both numbered 15**, and that one git did catch. Main's "Changes that arrive without a timer" keeps 15; installing and updating the desktop client becomes Phase 16, with its checks and every reference to them renumbered. The file's own rule is that a number is for life, which is exactly why the one that had not been pushed is the one that gives way. **The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and `EnforceCodeStyleInBuild` is on, so it failed the build on three lines of App.axaml.cs whose only change in this branch was an ADR number in a comment. Forty-six files normalised back to LF; the release script keeps CRLF, which is what `.gitattributes` and `.editorconfig` both already say for a PowerShell file. Nothing else conflicted. The updater does not touch the sync loop or the event stream, and the one file both sides edited heavily — MainWindowViewModel — merged without a hunk in common. Verified after merging: the solution restores locked and builds clean, and 304 shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The first two counts are higher than before the merge because main's own tests came with it and pass alongside these.
This commit is contained in:
@@ -11,11 +11,19 @@ namespace DodoSSH.Client.Sync;
|
||||
/// The fifth facade over the same generic repository, and like the fourth it needed no new sync logic at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deleting a group does not touch the hosts in it.</b> There is deliberately no <c>DeleteAsync</c>
|
||||
/// overload that unfiles its members: one user action would become N host writes, N outbox rows and N chances
|
||||
/// to merge against an edit nobody made, and the group's own tombstone can still lose a merge — by which time
|
||||
/// the membership it was clearing is gone. Hosts left holding a dangling id fall under the ungrouped heading,
|
||||
/// which is where the interface handles it. See <see cref="HostSecret.GroupId"/>.
|
||||
/// <b>Deleting a group still does not touch the hosts in it, here.</b> There is deliberately no
|
||||
/// <c>DeleteAsync</c> overload that unfiles its members: one call would become N host writes, N outbox rows
|
||||
/// and N chances to merge against an edit nobody made, and the group's own tombstone can still lose a merge —
|
||||
/// by which time the membership it was clearing would be gone. A host left holding a dangling id falls under
|
||||
/// the ungrouped heading, so this layer is safe whatever happens above it. See
|
||||
/// <see cref="HostSecret.GroupId"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What changed above it is that the deletion is no longer silent about the choice: the interface asks
|
||||
/// whether the hosts should go too, and writes them one by one through <c>HostRepository</c> either way —
|
||||
/// as N deletions, or as N hosts with the reference cleared. That is the same N writes, made where somebody
|
||||
/// asked for them and where the count can be shown before the fact rather than issued by a repository call
|
||||
/// that reads like one delete.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostGroupRepository(
|
||||
@@ -48,6 +56,25 @@ public sealed class HostGroupRepository(
|
||||
CancellationToken cancellationToken) =>
|
||||
groups.UpdateAsync(vaultId, entityId, group, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" path="/remarks" />
|
||||
/// <para>
|
||||
/// The new id matters more for a group than for anything else that can be moved, because a group is the
|
||||
/// one item other items point at. Everything naming the old id — the hosts filed under it, the groups
|
||||
/// nested inside it — has to be rewritten with the id this returns, or it is left pointing at a
|
||||
/// tombstone. That rewriting is the caller's, for the reason the <c>secret</c> parameter is: this layer
|
||||
/// cannot know which references cross with the group and which stay behind.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task<Guid> MoveAsync(
|
||||
Guid fromVaultId,
|
||||
Guid toVaultId,
|
||||
Guid entityId,
|
||||
HostGroupSecret group,
|
||||
CancellationToken cancellationToken) =>
|
||||
groups.MoveAsync(fromVaultId, toVaultId, entityId, group, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
groups.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
|
||||
@@ -38,6 +38,15 @@ public sealed class HostRepository(
|
||||
CancellationToken cancellationToken) =>
|
||||
hosts.UpdateAsync(vaultId, entityId, host, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
|
||||
public Task<Guid> MoveAsync(
|
||||
Guid fromVaultId,
|
||||
Guid toVaultId,
|
||||
Guid entityId,
|
||||
HostSecret host,
|
||||
CancellationToken cancellationToken) =>
|
||||
hosts.MoveAsync(fromVaultId, toVaultId, entityId, host, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
hosts.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
|
||||
@@ -234,6 +234,72 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves an item into another vault.
|
||||
/// </summary>
|
||||
/// <param name="fromVaultId">The vault it is in.</param>
|
||||
/// <param name="toVaultId">The vault it should be in.</param>
|
||||
/// <param name="entityId">The item.</param>
|
||||
/// <param name="secret">
|
||||
/// What to write into the destination. The caller's, rather than read from here, because moving is the
|
||||
/// one operation where the item does not arrive unchanged: references to things that live in the vault
|
||||
/// it is leaving are the mover's to resolve, and this layer has no way to know which those are.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The id the item has in its new vault.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A copy and a tombstone, and it cannot be anything else.</b> An item's payload is sealed under
|
||||
/// its vault's key and its AAD binds the vault, the entity id and the item version — so there is no
|
||||
/// edit that moves one, and no server call that could: the server holds ciphertext it cannot read.
|
||||
/// What crosses is the plaintext, in this process, between an unwrap under one key and a seal under
|
||||
/// another.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A new id, deliberately.</b> Keeping it would put one entity id in two vaults, and the item table
|
||||
/// is keyed on the type and the id rather than on the vault — so the destination's row and the
|
||||
/// source's tombstone would be the same row, and the move would delete what it had just written.
|
||||
/// Callers holding the old id have to take the new one back.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The write comes first and the tombstone second</b>, which decides what an interruption leaves
|
||||
/// behind: a copy in both vaults, which is visible and can be deleted, rather than a tombstone with
|
||||
/// nothing on the other side, which is the host gone. Both are queued rather than sent, so the window
|
||||
/// is a crash between two local writes — narrow, and worth choosing the survivable side of anyway.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two activity lines, not one: a create in the destination and a delete in the source, which is what
|
||||
/// the vaults actually record. A single "moved" line would have to be written to one of them and would
|
||||
/// be missing from the other's history.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<Guid> MoveAsync(
|
||||
Guid fromVaultId,
|
||||
Guid toVaultId,
|
||||
Guid entityId,
|
||||
TSecret secret,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(secret);
|
||||
|
||||
if (fromVaultId == toVaultId)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"That item is already in that vault.", nameof(toVaultId));
|
||||
}
|
||||
|
||||
// Both keys before either write, so a destination this session cannot write to is refused with
|
||||
// nothing having happened rather than after the source item has gone.
|
||||
_ = Key(fromVaultId);
|
||||
_ = Key(toVaultId);
|
||||
|
||||
var moved = await CreateAsync(toVaultId, secret, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await DeleteAsync(fromVaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an item.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user