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