From 8cb1457ca9329ed81b7d221b875feee733d3d2e4 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Fri, 2 Oct 2026 17:18:22 +0200 Subject: [PATCH] Server: collect tombstones past their retention, compact superseded change entries, and refuse cursors from before a collection --- src/DodoSSH.Api/Features/Sync/SyncLog.cs | 12 + src/DodoSSH.Api/Features/Sync/SyncService.cs | 28 +- .../Features/Sync/TombstoneCollector.cs | 198 ++ src/DodoSSH.Api/Program.cs | 4 + src/DodoSSH.Api/Setup/DodoOptions.cs | 4 + src/DodoSSH.Domain/Vaults.cs | 10 + ...1002151534_AddVaultSyncHorizon.Designer.cs | 2004 +++++++++++++++++ .../20261002151534_AddVaultSyncHorizon.cs | 31 + .../Migrations/DodoDbContextModelSnapshot.cs | 4 + tests/DodoSSH.Api.Tests/SyncEndpointTests.cs | 145 ++ 10 files changed, 2438 insertions(+), 2 deletions(-) create mode 100644 src/DodoSSH.Api/Features/Sync/TombstoneCollector.cs create mode 100644 src/DodoSSH.Infrastructure/Migrations/20261002151534_AddVaultSyncHorizon.Designer.cs create mode 100644 src/DodoSSH.Infrastructure/Migrations/20261002151534_AddVaultSyncHorizon.cs diff --git a/src/DodoSSH.Api/Features/Sync/SyncLog.cs b/src/DodoSSH.Api/Features/Sync/SyncLog.cs index cdb3856..b3a0755 100644 --- a/src/DodoSSH.Api/Features/Sync/SyncLog.cs +++ b/src/DodoSSH.Api/Features/Sync/SyncLog.cs @@ -27,4 +27,16 @@ internal static partial class SyncLog int appliedCount, int operationCount, Guid vaultId); + + [LoggerMessage( + EventId = 2003, + Level = LogLevel.Information, + Message = "Collected {Count} expired tombstone(s) and superseded change-log entries.")] + internal static partial void Collected(ILogger logger, int count); + + [LoggerMessage( + EventId = 2004, + Level = LogLevel.Warning, + Message = "Tombstone collection failed; it will run again at the next interval.")] + internal static partial void CollectionFailed(ILogger logger, Exception exception); } diff --git a/src/DodoSSH.Api/Features/Sync/SyncService.cs b/src/DodoSSH.Api/Features/Sync/SyncService.cs index bd59d94..5b6e001 100644 --- a/src/DodoSSH.Api/Features/Sync/SyncService.cs +++ b/src/DodoSSH.Api/Features/Sync/SyncService.cs @@ -62,6 +62,14 @@ internal sealed class SyncService( changes.RemoveAt(changes.Count - 1); } + // ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon + // in one transaction, so a read that already missed an entry is followed by a horizon that says so; + // checked first, the two could straddle a collection and the deletion would go unnoticed for good. + if (afterSequence > 0 && afterSequence < await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidCursorException(); + } + var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false); // When nothing came back the cursor must not move, or a concurrent write landing between @@ -622,14 +630,30 @@ internal sealed class SyncService( /// The kind behind a change-log entry, or null for a type this server does not synchronise. private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type)); - private async Task CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) => - await database.VaultChanges + /// + /// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a + /// head reported lower than a cursor the client already holds would rewind it under the horizon and + /// cost a full resync for nothing. + /// + private async Task CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) + { + var head = await database.VaultChanges .Where(c => c.VaultId == vaultId) .OrderByDescending(c => c.Sequence) .Select(c => c.Sequence) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); + return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false)); + } + + /// The vault's horizon as the database holds it now, not as the request loaded it. + private Task HorizonAsync(Guid vaultId, CancellationToken cancellationToken) => + database.Vaults + .Where(v => v.Id == vaultId) + .Select(v => v.SyncHorizon) + .SingleAsync(cancellationToken); + private static SyncPushResult Invalid(SyncPushOperation operation, string detail) => new( operation.OperationId, diff --git a/src/DodoSSH.Api/Features/Sync/TombstoneCollector.cs b/src/DodoSSH.Api/Features/Sync/TombstoneCollector.cs new file mode 100644 index 0000000..4ba840d --- /dev/null +++ b/src/DodoSSH.Api/Features/Sync/TombstoneCollector.cs @@ -0,0 +1,198 @@ +using DodoSSH.Api.Setup; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace DodoSSH.Api.Features.Sync; + +/// +/// Removes tombstones past their retention, and change-log entries a later one has superseded. +/// +/// +/// +/// ADR 0003 promised this and nothing did it: every deletion stayed a row for ever, and every edit stayed a +/// change-log entry for ever, so the log grew without bound and a client starting from nothing walked every +/// edit ever made to read the items that exist. +/// +/// +/// Superseded entries go at any time. A pull returns entries after a cursor in order, so a client +/// before both an item's old entry and its new one reads the new one, and a client between them already has +/// the old one. Nothing reads history: a conflict resolves against the newest entry alone. +/// +/// +/// Tombstones go after the retention, and taking them is the one change here a client can notice: a +/// machine that last synced before the deletion would never hear of it. So each vault records the highest +/// sequence collected from it — — and a pull from a cursor below that is +/// refused. The client answers by starting over, and a pull from nothing removes what the server no longer +/// has; see the client's SyncEngine. +/// +/// +/// Per vault under the same advisory lock a push takes, so a collection never interleaves with a write to +/// the vault — and the items are read again under it, so one restored since it was found is left alone. +/// +/// +internal sealed class TombstoneCollector( + DodoDbContext database, + IOptions syncOptions, + TimeProvider clock, + ILogger logger) +{ + /// Collects what is due, once. + /// How many tombstones and superseded entries went. + internal async Task RunOnceAsync(CancellationToken cancellationToken) + { + var before = clock.GetUtcNow() - TimeSpan.FromDays(syncOptions.Value.TombstoneRetentionDays); + + var collected = 0; + + collected += await CollectAsync(db => db.Hosts, ChangeEntityType.SshHost, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.SshKeys, ChangeEntityType.SshKey, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.Credentials, ChangeEntityType.Credential, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.KnownHostKeys, ChangeEntityType.KnownHostKey, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.HostGroups, ChangeEntityType.HostGroup, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.Tags, ChangeEntityType.Tag, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.Snippets, ChangeEntityType.Snippet, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false); + collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false); + + collected += await CompactAsync(cancellationToken).ConfigureAwait(false); + + SyncLog.Collected(logger, collected); + + return collected; + } + + private async Task CollectAsync( + Func> table, + ChangeEntityType type, + DateTimeOffset before, + CancellationToken cancellationToken) + where T : class, IVaultItem + { + var due = await table(database) + .Where(item => item.DeletedAtUtc != null && item.DeletedAtUtc < before) + .Select(item => new { item.VaultId, item.Id }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var collected = 0; + + foreach (var vault in due.GroupBy(item => item.VaultId)) + { + var ids = vault.Select(item => item.Id).ToArray(); + + collected += await database.ExecuteFreshAsync( + [], + () => CollectFromVaultAsync(table, type, vault.Key, ids, before, cancellationToken)) + .ConfigureAwait(false); + } + + return collected; + } + + private async Task CollectFromVaultAsync( + Func> table, + ChangeEntityType type, + Guid vaultId, + Guid[] ids, + DateTimeOffset before, + CancellationToken cancellationToken) + where T : class, IVaultItem + { + var transaction = await database.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await using var _ = transaction.ConfigureAwait(false); + + await database.Database + .ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", cancellationToken) + .ConfigureAwait(false); + + // Read again under the lock: an item restored since it was found is no longer due. + var still = await table(database) + .Where(item => item.VaultId == vaultId + && ids.Contains(item.Id) + && item.DeletedAtUtc != null + && item.DeletedAtUtc < before) + .Select(item => new { item.Id, item.ChangeSequence }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (still.Count == 0) + { + return 0; + } + + var going = still.Select(item => item.Id).ToArray(); + var horizon = still.Max(item => item.ChangeSequence); + + await table(database) + .Where(item => item.VaultId == vaultId && going.Contains(item.Id)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + await database.VaultChanges + .Where(change => change.VaultId == vaultId && change.EntityType == type && going.Contains(change.EntityId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + await database.Vaults + .Where(v => v.Id == vaultId && v.SyncHorizon < horizon) + .ExecuteUpdateAsync(set => set.SetProperty(v => v.SyncHorizon, horizon), cancellationToken) + .ConfigureAwait(false); + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + + return still.Count; + } + + /// Removes every change-log entry a later entry for the same item has superseded. + private Task CompactAsync(CancellationToken cancellationToken) => + database.Database.ExecuteSqlRawAsync( + "DELETE FROM " + DodoDbContext.SchemaName + ".sync_change AS older " + + "USING " + DodoDbContext.SchemaName + ".sync_change AS newer " + + "WHERE newer.vault_id = older.vault_id " + + "AND newer.entity_type = older.entity_type " + + "AND newer.entity_id = older.entity_id " + + "AND newer.sequence > older.sequence", + cancellationToken); +} + +/// Runs the on a schedule, in a scope of its own each time. +internal sealed class TombstoneCollectionService( + IServiceScopeFactory scopes, + IOptions syncOptions, + TimeProvider clock, + ILogger logger) : BackgroundService +{ + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var interval = TimeSpan.FromMinutes(syncOptions.Value.TombstoneCollectionIntervalMinutes); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(interval, clock, stoppingToken).ConfigureAwait(false); + + var scope = scopes.CreateAsyncScope(); + await using var _ = scope.ConfigureAwait(false); + + await scope.ServiceProvider.GetRequiredService() + .RunOnceAsync(stoppingToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + // The next run tries again; nothing collected late is harmed by waiting. + SyncLog.CollectionFailed(logger, exception); + } + } + } +} diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index fa5bc2e..50ab42f 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -48,6 +48,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// ADR 0003's tombstone collection, and the compaction of the change log that goes with it. +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + // The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004. builder.Services.AddDodoRelay(); diff --git a/src/DodoSSH.Api/Setup/DodoOptions.cs b/src/DodoSSH.Api/Setup/DodoOptions.cs index 077d612..1e19d12 100644 --- a/src/DodoSSH.Api/Setup/DodoOptions.cs +++ b/src/DodoSSH.Api/Setup/DodoOptions.cs @@ -289,6 +289,10 @@ public sealed class SyncOptions [Range(1, 3650)] public int TombstoneRetentionDays { get; set; } = 90; + /// How often the tombstone collector runs, in minutes. + [Range(1, 7 * 24 * 60)] + public int TombstoneCollectionIntervalMinutes { get; set; } = 6 * 60; + /// /// Key used to sign sync cursors, base64. /// diff --git a/src/DodoSSH.Domain/Vaults.cs b/src/DodoSSH.Domain/Vaults.cs index fe670b2..eda74e8 100644 --- a/src/DodoSSH.Domain/Vaults.cs +++ b/src/DodoSSH.Domain/Vaults.cs @@ -52,6 +52,16 @@ public sealed class Vault /// Soft-delete marker. public DateTimeOffset? DeletedAtUtc { get; set; } + /// + /// The highest change-log sequence whose tombstone has been collected from this vault. + /// + /// + /// A cursor below it may have been issued before a deletion this server no longer records, so a pull + /// from it is refused and the client starts over — see TombstoneCollector and ADR 0003. Zero until + /// anything has been collected. + /// + public long SyncHorizon { get; set; } + /// Wrapped vault keys, one per recipient per generation. public ICollection KeyGrants { get; } = []; diff --git a/src/DodoSSH.Infrastructure/Migrations/20261002151534_AddVaultSyncHorizon.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20261002151534_AddVaultSyncHorizon.Designer.cs new file mode 100644 index 0000000..acfe309 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20261002151534_AddVaultSyncHorizon.Designer.cs @@ -0,0 +1,2004 @@ +// +using System; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + [DbContext(typeof(DodoDbContext))] + [Migration("20261002151534_AddVaultSyncHorizon")] + partial class AddVaultSyncHorizon + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dodo") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DodoSSH.Domain.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EnrolledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at_utc"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at_utc"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Platform") + .HasColumnType("integer") + .HasColumnName("platform"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("public_key"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_device"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_device_user_id"); + + b.ToTable("device", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b => + { + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("EncryptionPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("encryption_public_key"); + + b.Property("Generation") + .HasColumnType("integer") + .HasColumnName("generation"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("PreviousHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("previous_hash"); + + b.Property("SigningPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("signing_public_key"); + + b.Property("StatementSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("statement_signature"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Sequence") + .HasName("pk_key_log"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_key_log_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_key_log_user_id"); + + b.ToTable("key_log", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.RelaySession", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BytesClientToTarget") + .HasColumnType("bigint") + .HasColumnName("bytes_client_to_target"); + + b.Property("BytesTargetToClient") + .HasColumnType("bigint") + .HasColumnName("bytes_target_to_client"); + + b.Property("ClientAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("client_address"); + + b.Property("CloseReason") + .HasColumnType("integer") + .HasColumnName("close_reason"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at_utc"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at_utc"); + + b.Property("TargetAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("target_address"); + + b.Property("TargetHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("target_host"); + + b.Property("TargetPort") + .HasColumnType("integer") + .HasColumnName("target_port"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.HasKey("Id") + .HasName("pk_relay_session"); + + b.HasIndex("HostId", "StartedAtUtc") + .HasDatabaseName("ix_relay_session_host_id_started_at_utc"); + + b.HasIndex("UserId", "StartedAtUtc") + .HasDatabaseName("ix_relay_session_user_id_started_at_utc"); + + b.ToTable("relay_session", "dodo", t => + { + t.HasCheckConstraint("ck_relay_session_bytes", "bytes_client_to_target >= 0 AND bytes_target_to_client >= 0"); + + t.HasCheckConstraint("ck_relay_session_port_range", "target_port BETWEEN 1 AND 65535"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.RelayTicketUse", b => + { + b.Property("TicketId") + .HasColumnType("uuid") + .HasColumnName("ticket_id"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("UsedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at_utc"); + + b.HasKey("TicketId") + .HasName("pk_relay_ticket_use"); + + b.HasIndex("ExpiresAtUtc") + .HasDatabaseName("ix_relay_ticket_use_expires_at_utc"); + + b.ToTable("relay_ticket_use", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.SshHost", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("Hostname") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("hostname"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("Port") + .HasColumnType("integer") + .HasColumnName("port"); + + b.Property("RelayEnabled") + .HasColumnType("boolean") + .HasColumnName("relay_enabled"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_host"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_host_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_host_vault_id_change_sequence"); + + b.ToTable("host", "dodo", t => + { + t.HasCheckConstraint("ck_host_port_range", "port IS NULL OR (port BETWEEN 1 AND 65535)"); + + t.HasCheckConstraint("ck_host_relay_target", "(relay_enabled AND hostname IS NOT NULL AND port IS NOT NULL)\nOR (NOT relay_enabled AND hostname IS NULL AND port IS NULL)"); + + t.HasCheckConstraint("ck_host_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b => + { + b.Property("OperationId") + .HasColumnType("uuid") + .HasColumnName("operation_id"); + + b.Property("AppliedChangeSequence") + .HasColumnType("bigint") + .HasColumnName("applied_change_sequence"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("ResultVersion") + .HasColumnType("integer") + .HasColumnName("result_version"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.HasKey("OperationId") + .HasName("pk_sync_operation_receipt"); + + b.HasIndex("VaultId", "CreatedAtUtc") + .HasDatabaseName("ix_sync_operation_receipt_vault_id_created_at_utc"); + + b.ToTable("sync_operation_receipt", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Team", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("citext") + .HasColumnName("slug"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_team"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_team_slug") + .HasFilter("deleted_at_utc IS NULL"); + + b.ToTable("team", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid") + .HasColumnName("invited_by_user_id"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at_utc"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_team_membership"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_team_membership_user_id"); + + b.HasIndex("TeamId", "UserId") + .IsUnique() + .HasDatabaseName("ix_team_membership_team_id_user_id") + .HasFilter("deleted_at_utc IS NULL"); + + b.ToTable("team_membership", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserAccount", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("EnrolledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at_utc"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("issuer"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at_utc"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("subject"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_user_account"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_user_account_email") + .HasFilter("email IS NOT NULL AND deleted_at_utc IS NULL"); + + b.HasIndex("Issuer", "Subject") + .IsUnique() + .HasDatabaseName("ix_user_account_issuer_subject"); + + b.ToTable("user_account", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("EncryptionPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("encryption_public_key"); + + b.Property("FingerprintSha256") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("fingerprint_sha256"); + + b.Property("Generation") + .HasColumnType("integer") + .HasColumnName("generation"); + + b.Property("IdentityProviderBinding") + .HasColumnType("jsonb") + .HasColumnName("identity_provider_binding"); + + b.Property("IsCurrent") + .HasColumnType("boolean") + .HasColumnName("is_current"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("SigningPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("signing_public_key"); + + b.Property("Statement") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("statement"); + + b.Property("StatementSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("statement_signature"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_key"); + + b.HasIndex("FingerprintSha256") + .IsUnique() + .HasDatabaseName("ix_user_key_fingerprint_sha256"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_user_key_current") + .HasFilter("is_current"); + + b.HasIndex("UserId", "Generation") + .IsUnique() + .HasDatabaseName("ix_user_key_user_id_generation"); + + b.ToTable("user_key", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKeyWrap", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("KdfAlgorithm") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("kdf_algorithm"); + + b.Property("KdfMemoryKibibytes") + .HasColumnType("integer") + .HasColumnName("kdf_memory_kibibytes"); + + b.Property("KdfParallelism") + .HasColumnType("integer") + .HasColumnName("kdf_parallelism"); + + b.Property("KdfPasses") + .HasColumnType("integer") + .HasColumnName("kdf_passes"); + + b.Property("KdfSalt") + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("kdf_salt"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at_utc"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Wrap") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("wrap"); + + b.Property("WrapVersion") + .HasColumnType("integer") + .HasColumnName("wrap_version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_user_key_wrap"); + + b.HasIndex("DeviceId") + .HasDatabaseName("ix_user_key_wrap_device_id"); + + b.HasIndex("UserId", "DeviceId") + .IsUnique() + .HasDatabaseName("ix_user_key_wrap_user_device") + .HasFilter("device_id IS NOT NULL"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasDatabaseName("ix_user_key_wrap_user_kind") + .HasFilter("device_id IS NULL"); + + b.ToTable("user_key_wrap", "dodo", t => + { + t.HasCheckConstraint("ck_user_key_wrap_device", "(kind = 2 AND device_id IS NOT NULL) OR (kind <> 2 AND device_id IS NULL)"); + + t.HasCheckConstraint("ck_user_key_wrap_kdf", "(kind IN (1, 3) AND kdf_algorithm IS NOT NULL AND kdf_salt IS NOT NULL\n AND kdf_memory_kibibytes IS NOT NULL AND kdf_passes IS NOT NULL\n AND kdf_parallelism IS NOT NULL)\nOR (kind IN (2, 4) AND kdf_algorithm IS NULL AND kdf_salt IS NULL)"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerKind") + .HasColumnType("integer") + .HasColumnName("owner_kind"); + + b.Property("OwnerUserId") + .HasColumnType("uuid") + .HasColumnName("owner_user_id"); + + b.Property("RekeyReason") + .HasColumnType("integer") + .HasColumnName("rekey_reason"); + + b.Property("RekeyRequired") + .HasColumnType("boolean") + .HasColumnName("rekey_required"); + + b.Property("SyncHorizon") + .HasColumnType("bigint") + .HasColumnName("sync_horizon"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_vault"); + + b.HasIndex("OwnerUserId") + .HasDatabaseName("ix_vault_owner_user_id"); + + b.HasIndex("TeamId") + .HasDatabaseName("ix_vault_team_id"); + + b.ToTable("vault", "dodo", t => + { + t.HasCheckConstraint("ck_vault_key_generation", "key_generation >= 1"); + + t.HasCheckConstraint("ck_vault_owner", "(owner_kind = 1 AND owner_user_id IS NOT NULL AND team_id IS NULL)\nOR (owner_kind = 2 AND team_id IS NOT NULL AND owner_user_id IS NULL)"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultActivityLogEntry", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_activity_log_entry"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_activity_log_entry_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_activity_log_entry_vault_id_change_sequence"); + + b.ToTable("activity_log_entry", "dodo", t => + { + t.HasCheckConstraint("ck_activity_log_entry_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultChange", b => + { + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("entity_type"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("Operation") + .HasColumnType("integer") + .HasColumnName("operation"); + + b.Property("Revision") + .HasColumnType("integer") + .HasColumnName("revision"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.HasKey("Sequence") + .HasName("pk_sync_change"); + + b.HasIndex("VaultId", "Sequence") + .HasDatabaseName("ix_sync_change_vault_id_sequence"); + + b.HasIndex("VaultId", "EntityId", "Sequence") + .IsDescending(false, false, true) + .HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence"); + + b.ToTable("sync_change", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultConnectionLogEntry", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_connection_log_entry"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_connection_log_entry_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_connection_log_entry_vault_id_change_sequence"); + + b.ToTable("connection_log_entry", "dodo", t => + { + t.HasCheckConstraint("ck_connection_log_entry_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_credential"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_credential_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_credential_vault_id_change_sequence"); + + b.ToTable("credential", "dodo", t => + { + t.HasCheckConstraint("ck_credential_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultHostGroup", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_host_group"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_host_group_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_host_group_vault_id_change_sequence"); + + b.ToTable("host_group", "dodo", t => + { + t.HasCheckConstraint("ck_host_group_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("GranterKeyFingerprint") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("granter_key_fingerprint"); + + b.Property("GranterUserId") + .HasColumnType("uuid") + .HasColumnName("granter_user_id"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("KeyLogHead") + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("key_log_head"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("RecipientKeyFingerprint") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("recipient_key_fingerprint"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("recipient_user_id"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("signature"); + + b.Property("State") + .HasColumnType("integer") + .HasColumnName("state"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("WrappedKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("wrapped_key"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_vault_key_grant"); + + b.HasIndex("RecipientUserId") + .HasDatabaseName("ix_vault_key_grant_recipient_user_id"); + + b.HasIndex("VaultId", "KeyGeneration", "RecipientUserId") + .IsUnique() + .HasDatabaseName("ix_vault_key_grant_vault_id_key_generation_recipient_user_id") + .HasFilter("revoked_at_utc IS NULL AND recipient_user_id IS NOT NULL"); + + b.ToTable("vault_key_grant", "dodo", t => + { + t.HasCheckConstraint("ck_vault_key_grant_recipient", "(kind = 1 AND recipient_user_id IS NOT NULL) OR (kind <> 1 AND recipient_user_id IS NULL)"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultKnownHostKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_known_host_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_known_host_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_known_host_key_vault_id_change_sequence"); + + b.ToTable("known_host_key", "dodo", t => + { + t.HasCheckConstraint("ck_known_host_key_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultObjectStore", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_object_store"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_object_store_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_object_store_vault_id_change_sequence"); + + b.ToTable("object_store", "dodo", t => + { + t.HasCheckConstraint("ck_object_store_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSnippet", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_snippet"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_snippet_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_snippet_vault_id_change_sequence"); + + b.ToTable("snippet", "dodo", t => + { + t.HasCheckConstraint("ck_snippet_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_spelunk_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_spelunk_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_spelunk_key_vault_id_change_sequence"); + + b.ToTable("spelunk_key", "dodo", t => + { + t.HasCheckConstraint("ck_spelunk_key_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("PublicKeyFingerprint") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("public_key_fingerprint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_ssh_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_ssh_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_ssh_key_vault_id_change_sequence"); + + b.ToTable("ssh_key", "dodo", t => + { + t.HasCheckConstraint("ck_ssh_key_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultTag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_tag"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_tag_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_tag_vault_id_change_sequence"); + + b.ToTable("tag", "dodo", t => + { + t.HasCheckConstraint("ck_tag_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.Device", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("Devices") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.SshHost", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany("Hosts") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_host_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b => + { + b.HasOne("DodoSSH.Domain.Team", "Team") + .WithMany("Memberships") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_team_membership_team_team_id"); + + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_team_membership_users_user_id"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKey", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("Keys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_key_user_account_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKeyWrap", b => + { + b.HasOne("DodoSSH.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_key_wrap_device_device_id"); + + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("KeyWraps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_key_wrap_user_account_user_id"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vault_user_account_owner_user_id"); + + b.HasOne("DodoSSH.Domain.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vault_team_team_id"); + + b.Navigation("OwnerUser"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultActivityLogEntry", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_activity_log_entry_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultConnectionLogEntry", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_connection_log_entry_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_credential_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultHostGroup", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_host_group_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "RecipientUser") + .WithMany() + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_vault_key_grant_user_account_recipient_user_id"); + + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany("KeyGrants") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vault_key_grant_vault_vault_id"); + + b.Navigation("RecipientUser"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultKnownHostKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_known_host_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultObjectStore", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_object_store_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSnippet", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_snippet_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_spelunk_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ssh_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultTag", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tag_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Team", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserAccount", b => + { + b.Navigation("Devices"); + + b.Navigation("KeyWraps"); + + b.Navigation("Keys"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.Navigation("Hosts"); + + b.Navigation("KeyGrants"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/20261002151534_AddVaultSyncHorizon.cs b/src/DodoSSH.Infrastructure/Migrations/20261002151534_AddVaultSyncHorizon.cs new file mode 100644 index 0000000..be38a14 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20261002151534_AddVaultSyncHorizon.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + /// + public partial class AddVaultSyncHorizon : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "sync_horizon", + schema: "dodo", + table: "vault", + type: "bigint", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "sync_horizon", + schema: "dodo", + table: "vault"); + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs index 4e7f4c8..ad73147 100644 --- a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs +++ b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs @@ -750,6 +750,10 @@ namespace DodoSSH.Infrastructure.Migrations .HasColumnType("boolean") .HasColumnName("rekey_required"); + b.Property("SyncHorizon") + .HasColumnType("bigint") + .HasColumnName("sync_horizon"); + b.Property("TeamId") .HasColumnType("uuid") .HasColumnName("team_id"); diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs index 4c6e9ef..8342067 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using DodoSSH.Api.Features.Sync; using DodoSSH.Contracts; using DodoSSH.Domain; using DodoSSH.Infrastructure; @@ -612,6 +613,150 @@ public sealed class SyncEndpointTests(ApiFixture fixture) tombstone.PlaintextFields.ShouldBeNull(); } + // ---- Tombstone collection (ADR 0003) ---- + + /// + /// + /// A tombstone past its retention goes, with its change-log entries — and a client whose cursor is from + /// before it is refused, because it would otherwise never hear of the deletion. A client already past it + /// carries on, and a pull from nothing simply no longer offers the item. + /// + /// + /// The retention is crossed by moving the deletion back in time rather than by waiting it out. + /// + /// + [Fact] + public async Task AnExpiredTombstone_IsCollected_AndACursorFromBeforeIt_IsRefused() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var create = NewCreateBatch(); + var entityId = create.Operations[0].EntityId; + await client.PostContractAsync(PushUrl(vaultId), create); + + var before = await PullAsync(client, vaultId, null); + + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( + [ + new SyncPushOperation( + Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete, + ExpectedVersion: 1, Payload: null, PlaintextFields: null), + ])); + + var after = await PullAsync(client, vaultId, before.NextCursor); + + await AgeTheDeletionAsync(entityId); + await CollectAsync(); + + var refused = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(before.NextCursor, null, null)); + refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await refused.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.InvalidCursor); + + (await PullAsync(client, vaultId, after.NextCursor)).Changes.ShouldBeEmpty("a cursor past the deletion is fine"); + + (await PullAsync(client, vaultId, null)).Changes.ShouldNotContain(change => change.EntityId == entityId); + } + + /// + /// The other half of the retention: a deletion younger than it stays, entries and all, so a machine that + /// has been away a week still hears of it. + /// + [Fact] + public async Task ATombstoneInsideItsRetention_IsKept() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var create = NewCreateBatch(); + var entityId = create.Operations[0].EntityId; + await client.PostContractAsync(PushUrl(vaultId), create); + + var before = await PullAsync(client, vaultId, null); + + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( + [ + new SyncPushOperation( + Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete, + ExpectedVersion: 1, Payload: null, PlaintextFields: null), + ])); + + await CollectAsync(); + + var later = await PullAsync(client, vaultId, before.NextCursor); + + later.Changes.ShouldHaveSingleItem().Operation.ShouldBe(SyncOperation.Delete); + } + + /// + /// An item edited many times kept an entry per edit for ever, and a client starting from nothing walked + /// every one of them. After a collection only the newest is left — and a client that held a cursor from + /// between the edits still gets the item, because what it had not seen yet is exactly that newest entry. + /// + [Fact] + public async Task SupersededEntries_AreCompactedAway_WithoutCostingAnyClientTheItem() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var create = NewCreateBatch(); + var entityId = create.Operations[0].EntityId; + await client.PostContractAsync(PushUrl(vaultId), create); + + var between = await PullAsync(client, vaultId, null); + + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( + [ + NewOperation(entityId, expectedVersion: 1, envelope: [2, 2, 2]), + ])); + + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( + [ + NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]), + ])); + + await CollectAsync(); + + await using (var scope = fixture.CreateScope()) + { + var database = scope.ServiceProvider.GetRequiredService(); + + (await database.VaultChanges.CountAsync(c => c.EntityId == entityId, TestContext.Current.CancellationToken)) + .ShouldBe(1); + } + + (await PullAsync(client, vaultId, between.NextCursor)).Changes.ShouldHaveSingleItem().Version.ShouldBe(3); + (await PullAsync(client, vaultId, null)).Changes.Single(c => c.EntityId == entityId).Version.ShouldBe(3); + } + + private static async Task PullAsync(HttpClient client, Guid vaultId, string? cursor) + { + var response = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(cursor, null, null)); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + return (await response.Content.ReadContractAsync()).ShouldNotBeNull(); + } + + /// Moves a deletion back past the retention, which is quicker than waiting ninety days. + private async Task AgeTheDeletionAsync(Guid entityId) + { + await using var scope = fixture.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var host = await database.Hosts.SingleAsync(h => h.Id == entityId, TestContext.Current.CancellationToken); + host.DeletedAtUtc = host.DeletedAtUtc!.Value.AddDays(-91); + + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + private async Task CollectAsync() + { + await using var scope = fixture.CreateScope(); + + await scope.ServiceProvider.GetRequiredService().RunOnceAsync(TestContext.Current.CancellationToken); + } + // ---- Batch limits ---- [Fact]