Stop hitting duplicate PK errors

This commit is contained in:
Paul Schneider 2026-07-05 22:15:25 +01:00
commit 87143f4562
8 changed files with 562 additions and 16 deletions

View file

@ -145,7 +145,7 @@ namespace isnd.Controllers
dbContext.PackageVersions.Remove(v);
}
// FIXME default type or null
// Default package type must be non-null for composite key consistency.
if (types==null || types.Count==0)
dbContext.PackageVersions.Add
(new PackageVersion{
@ -157,7 +157,7 @@ namespace isnd.Controllers
Revision = version.Revision,
IsPrerelease = version.IsPrerelease,
FullString = version.ToFullString(),
Type = null,
Type = string.Empty,
LatestCommit = commit
});
else
@ -171,7 +171,7 @@ namespace isnd.Controllers
Patch = version.Patch,
IsPrerelease = version.IsPrerelease,
FullString = version.ToFullString(),
Type = type.Name,
Type = type.Name ?? string.Empty,
LatestCommit = commit
};
dbContext.PackageVersions.Add(pkgver);

View file

@ -60,6 +60,7 @@ namespace isnd.Controllers
{
return NotFound();
}
pkgtype ??= string.Empty;
// var report = await packageManager.DeletePackageAsync(id, lower, type);
var packageVersion = await dbContext.PackageVersions.Include(p => p.Package)
@ -77,6 +78,7 @@ namespace isnd.Controllers
public async Task<IActionResult> DeleteConfirmed(string PackageId, string FullString,
string Type)
{
Type ??= string.Empty;
PackageVersion packageVersion = await dbContext.PackageVersions
.Include(pv => pv.Package)
.FirstOrDefaultAsync(m => m.PackageId == PackageId

View file

@ -22,6 +22,19 @@ namespace isnd.Data
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<PackageVersion>(entity =>
{
entity.HasKey(v => new { v.PackageId, v.FullString, v.Type });
entity.Property(v => v.Type)
.IsRequired()
.HasDefaultValue(string.Empty);
});
}
/// <summary>
/// User API keys
/// </summary>

View file

@ -38,7 +38,6 @@ namespace isnd.Data
/// <value></value>
[StringLength(256)]
[Required]
[Key]
public string FullString { get; set; }
public bool IsPrerelease { get; set; }

View file

@ -0,0 +1,457 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using isnd.Data;
#nullable disable
namespace isndhost.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260705211205_pkgver_composite_identity_fix")]
partial class pkgver_composite_identity_fix
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("isnd.Data.ApiKeys.ApiKey", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ValidityPeriodInDays")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("isnd.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("FullName")
.HasColumnType("text");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("isnd.Data.PackageVersion", b =>
{
b.Property<string>("PackageId")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("FullString")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Type")
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasDefaultValue("");
b.Property<long>("CommitNId")
.HasColumnType("bigint");
b.Property<bool>("IsPrerelease")
.HasColumnType("boolean");
b.Property<int>("Major")
.HasColumnType("integer");
b.Property<int>("Minor")
.HasColumnType("integer");
b.Property<int>("Patch")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.HasKey("PackageId", "FullString", "Type");
b.HasIndex("CommitNId");
b.ToTable("PackageVersions");
});
modelBuilder.Entity("isnd.Data.Packages.Commit", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("Action")
.HasColumnType("integer");
b.Property<DateTime>("TimeStamp")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Commits");
});
modelBuilder.Entity("isnd.Data.Packages.Package", b =>
{
b.Property<string>("Id")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<long>("CommitNId")
.HasColumnType("bigint");
b.Property<DateTime>("CommitTimeStamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Public")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("CommitNId");
b.HasIndex("OwnerId");
b.ToTable("Packages");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("isnd.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("isnd.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("isnd.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("isnd.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("isnd.Data.ApiKeys.ApiKey", b =>
{
b.HasOne("isnd.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("isnd.Data.PackageVersion", b =>
{
b.HasOne("isnd.Data.Packages.Commit", "LatestCommit")
.WithMany("Versions")
.HasForeignKey("CommitNId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("isnd.Data.Packages.Package", "Package")
.WithMany("Versions")
.HasForeignKey("PackageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LatestCommit");
b.Navigation("Package");
});
modelBuilder.Entity("isnd.Data.Packages.Package", b =>
{
b.HasOne("isnd.Data.Packages.Commit", "LatestVersion")
.WithMany()
.HasForeignKey("CommitNId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("isnd.Data.ApplicationUser", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LatestVersion");
b.Navigation("Owner");
});
modelBuilder.Entity("isnd.Data.Packages.Commit", b =>
{
b.Navigation("Versions");
});
modelBuilder.Entity("isnd.Data.Packages.Package", b =>
{
b.Navigation("Versions");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace isndhost.Migrations
{
/// <inheritdoc />
public partial class pkgver_composite_identity_fix : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_PackageVersions",
table: "PackageVersions");
migrationBuilder.DropIndex(
name: "IX_PackageVersions_PackageId",
table: "PackageVersions");
migrationBuilder.Sql("UPDATE \"PackageVersions\" SET \"Type\" = '' WHERE \"Type\" IS NULL;");
migrationBuilder.AlterColumn<string>(
name: "Type",
table: "PackageVersions",
type: "character varying(256)",
maxLength: 256,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldNullable: true);
migrationBuilder.AddPrimaryKey(
name: "PK_PackageVersions",
table: "PackageVersions",
columns: new[] { "PackageId", "FullString", "Type" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_PackageVersions",
table: "PackageVersions");
migrationBuilder.AlterColumn<string>(
name: "Type",
table: "PackageVersions",
type: "character varying(256)",
maxLength: 256,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldDefaultValue: "");
migrationBuilder.Sql("UPDATE \"PackageVersions\" SET \"Type\" = NULL WHERE \"Type\" = '';");
migrationBuilder.AddPrimaryKey(
name: "PK_PackageVersions",
table: "PackageVersions",
column: "FullString");
migrationBuilder.CreateIndex(
name: "IX_PackageVersions_PackageId",
table: "PackageVersions",
column: "PackageId");
}
}
}

View file

@ -249,10 +249,20 @@ namespace isndhost.Migrations
modelBuilder.Entity("isnd.Data.PackageVersion", b =>
{
b.Property<string>("PackageId")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("FullString")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Type")
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasDefaultValue("");
b.Property<long>("CommitNId")
.HasColumnType("bigint");
@ -265,27 +275,16 @@ namespace isndhost.Migrations
b.Property<int>("Minor")
.HasColumnType("integer");
b.Property<string>("PackageId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("Patch")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("Type")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("FullString");
b.HasKey("PackageId", "FullString", "Type");
b.HasIndex("CommitNId");
b.HasIndex("PackageId");
b.ToTable("PackageVersions");
});

View file

@ -18,6 +18,7 @@ namespace isnd.Services
public class PackageManager : IPackageManager
{
public const string BASE_API_LEVEL = "3.0.0";
private static string NormalizePackageType(string packageType) => packageType ?? string.Empty;
ApplicationDbContext dbContext;
@ -190,6 +191,7 @@ namespace isnd.Services
public async Task<PackageDeletionReport> DeletePackageAsync(string pkgid, string version, string type)
{
type = NormalizePackageType(type);
// TODO deletion on disk
var commit = new Commit
{
@ -214,6 +216,7 @@ namespace isnd.Services
public async Task<PackageVersion> GetPackageAsync(string pkgid, string version, string type)
{
type = NormalizePackageType(type);
return await dbContext.PackageVersions.SingleOrDefaultAsync(
v => v.PackageId == pkgid &&
v.FullString == version &&
@ -234,6 +237,7 @@ namespace isnd.Services
public async Task<PackageDeletionReport> UserAskForPackageDeletionAsync(string uid, string id, string lower, string type)
{
type = NormalizePackageType(type);
PackageVersion packageVersion = await dbContext.PackageVersions
.Include(pv => pv.Package)
.FirstOrDefaultAsync(m => m.PackageId == id