feat: add pocketbase import audit persistence

This commit is contained in:
xiong
2026-07-26 06:05:24 +08:00
parent a2b10fb05b
commit 2451c4b7a8
7 changed files with 15843 additions and 1 deletions

View File

@@ -0,0 +1,45 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Import;
public sealed class PocketBaseImportRun : Entity
{
public Guid TenantId { get; set; }
public string SourceName { get; set; } = string.Empty;
public PocketBaseImportSourceKind SourceKind { get; set; } = PocketBaseImportSourceKind.Json;
public PocketBaseImportRunStatus Status { get; set; } = PocketBaseImportRunStatus.Running;
public JsonElement Stats { get; set; } = JsonDefaults.Object();
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? FinishedAt { get; set; }
}
public sealed class PocketBaseRawRecord
{
public Guid RunId { get; set; }
public Guid TenantId { get; set; }
public string CollectionName { get; set; } = string.Empty;
public string LegacyId { get; set; } = string.Empty;
public JsonElement Record { get; set; } = JsonDefaults.Object();
public bool Normalized { get; set; }
public JsonElement Errors { get; set; } = JsonDefaults.Array();
public DateTimeOffset ImportedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class PocketBaseImportIssue : Entity
{
public Guid? RunId { get; set; }
public Guid TenantId { get; set; }
public string CollectionName { get; set; } = string.Empty;
public string? LegacyId { get; set; }
public PocketBaseImportIssueSeverity Severity { get; set; } = PocketBaseImportIssueSeverity.Warning;
public string IssueCode { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? FieldPath { get; set; }
public string? RawValueSample { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public enum PocketBaseImportSourceKind { Json, Sqlite, Api, Archive }
public enum PocketBaseImportRunStatus { Running, Completed, Failed }
public enum PocketBaseImportIssueSeverity { Info, Warning, Error, Critical }

View File

@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Import;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class PocketBaseImportRunConfiguration : IEntityTypeConfiguration<PocketBaseImportRun>
{
public void Configure(EntityTypeBuilder<PocketBaseImportRun> builder)
{
builder.ConfigureEntity("pb_import_runs");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.SourceName).HasMaxLength(500);
builder.Property(entity => entity.SourceKind).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Stats).IsJson("{}");
builder.Property(entity => entity.StartedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.StartedAt });
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PocketBaseRawRecordConfiguration : IEntityTypeConfiguration<PocketBaseRawRecord>
{
public void Configure(EntityTypeBuilder<PocketBaseRawRecord> builder)
{
builder.ToTable("pb_raw_records");
builder.HasKey(entity => new { entity.RunId, entity.CollectionName, entity.LegacyId });
builder.Property(entity => entity.CollectionName).HasMaxLength(100);
builder.Property(entity => entity.LegacyId).HasMaxLength(100);
builder.Property(entity => entity.Record).IsJson("{}");
builder.Property(entity => entity.Errors).IsJson("[]");
builder.Property(entity => entity.ImportedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.CollectionName, entity.LegacyId });
builder.HasOne<PocketBaseImportRun>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RunId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PocketBaseImportIssueConfiguration : IEntityTypeConfiguration<PocketBaseImportIssue>
{
public void Configure(EntityTypeBuilder<PocketBaseImportIssue> builder)
{
builder.ConfigureEntity("pb_import_issues");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.CollectionName).HasMaxLength(100);
builder.Property(entity => entity.LegacyId).HasMaxLength(100);
builder.Property(entity => entity.Severity).HasSnakeCaseEnum();
builder.Property(entity => entity.IssueCode).HasMaxLength(100);
builder.Property(entity => entity.FieldPath).HasMaxLength(500);
builder.Property(entity => entity.RawValueSample).HasMaxLength(2000);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.RunId, entity.Severity, entity.CollectionName });
builder.HasIndex(entity => new { entity.TenantId, entity.CollectionName, entity.LegacyId });
builder.HasOne<PocketBaseImportRun>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RunId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,148 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPocketBaseImportAuditPersistence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "pb_import_runs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
source_name = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
source_kind = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
stats = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
finished_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_pb_import_runs", x => x.id);
table.UniqueConstraint("ak_pb_import_runs_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_pb_import_runs_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "pb_import_issues",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
run_id = table.Column<Guid>(type: "uuid", nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
collection_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
legacy_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
severity = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
issue_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
message = table.Column<string>(type: "text", nullable: false),
field_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
raw_value_sample = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_pb_import_issues", x => x.id);
table.UniqueConstraint("ak_pb_import_issues_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_pb_import_issues_pb_import_runs_tenant_id_run_id",
columns: x => new { x.tenant_id, x.run_id },
principalTable: "pb_import_runs",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_pb_import_issues_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "pb_raw_records",
columns: table => new
{
run_id = table.Column<Guid>(type: "uuid", nullable: false),
collection_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
legacy_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
record = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
normalized = table.Column<bool>(type: "boolean", nullable: false),
errors = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
imported_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_pb_raw_records", x => new { x.run_id, x.collection_name, x.legacy_id });
table.ForeignKey(
name: "fk_pb_raw_records_pb_import_runs_tenant_id_run_id",
columns: x => new { x.tenant_id, x.run_id },
principalTable: "pb_import_runs",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_pb_raw_records_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_pb_import_issues_run_id_severity_collection_name",
table: "pb_import_issues",
columns: new[] { "run_id", "severity", "collection_name" });
migrationBuilder.CreateIndex(
name: "ix_pb_import_issues_tenant_id_collection_name_legacy_id",
table: "pb_import_issues",
columns: new[] { "tenant_id", "collection_name", "legacy_id" });
migrationBuilder.CreateIndex(
name: "ix_pb_import_issues_tenant_id_run_id",
table: "pb_import_issues",
columns: new[] { "tenant_id", "run_id" });
migrationBuilder.CreateIndex(
name: "ix_pb_import_runs_tenant_id_status_started_at",
table: "pb_import_runs",
columns: new[] { "tenant_id", "status", "started_at" });
migrationBuilder.CreateIndex(
name: "ix_pb_raw_records_tenant_id_collection_name_legacy_id",
table: "pb_raw_records",
columns: new[] { "tenant_id", "collection_name", "legacy_id" });
migrationBuilder.CreateIndex(
name: "ix_pb_raw_records_tenant_id_run_id",
table: "pb_raw_records",
columns: new[] { "tenant_id", "run_id" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "pb_import_issues");
migrationBuilder.DropTable(
name: "pb_raw_records");
migrationBuilder.DropTable(
name: "pb_import_runs");
}
}
}

View File

@@ -7262,6 +7262,196 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("user_identities", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportIssue", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CollectionName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("collection_name");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("FieldPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("field_path");
b.Property<string>("IssueCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("issue_code");
b.Property<string>("LegacyId")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("legacy_id");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message");
b.Property<string>("RawValueSample")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("raw_value_sample");
b.Property<Guid?>("RunId")
.HasColumnType("uuid")
.HasColumnName("run_id");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("severity");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id")
.HasName("pk_pb_import_issues");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_pb_import_issues_tenant_id_id");
b.HasIndex("TenantId", "RunId")
.HasDatabaseName("ix_pb_import_issues_tenant_id_run_id");
b.HasIndex("RunId", "Severity", "CollectionName")
.HasDatabaseName("ix_pb_import_issues_run_id_severity_collection_name");
b.HasIndex("TenantId", "CollectionName", "LegacyId")
.HasDatabaseName("ix_pb_import_issues_tenant_id_collection_name_legacy_id");
b.ToTable("pb_import_issues", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("finished_at");
b.Property<string>("SourceKind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("source_kind");
b.Property<string>("SourceName")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("source_name");
b.Property<DateTimeOffset>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("Stats")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("stats")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("Id")
.HasName("pk_pb_import_runs");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_pb_import_runs_tenant_id_id");
b.HasIndex("TenantId", "Status", "StartedAt")
.HasDatabaseName("ix_pb_import_runs_tenant_id_status_started_at");
b.ToTable("pb_import_runs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Import.PocketBaseRawRecord", b =>
{
b.Property<Guid>("RunId")
.HasColumnType("uuid")
.HasColumnName("run_id");
b.Property<string>("CollectionName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("collection_name");
b.Property<string>("LegacyId")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("legacy_id");
b.Property<JsonElement>("Errors")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("errors")
.HasDefaultValueSql("'[]'::jsonb");
b.Property<DateTimeOffset>("ImportedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("imported_at")
.HasDefaultValueSql("now()");
b.Property<bool>("Normalized")
.HasColumnType("boolean")
.HasColumnName("normalized");
b.Property<JsonElement>("Record")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("record")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.HasKey("RunId", "CollectionName", "LegacyId")
.HasName("pk_pb_raw_records");
b.HasIndex("TenantId", "RunId")
.HasDatabaseName("ix_pb_raw_records_tenant_id_run_id");
b.HasIndex("TenantId", "CollectionName", "LegacyId")
.HasDatabaseName("ix_pb_raw_records_tenant_id_collection_name_legacy_id");
b.ToTable("pb_raw_records", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Learning.AnswerRecord", b =>
{
b.Property<Guid>("Id")
@@ -13955,6 +14145,51 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_user_identities_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportIssue", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_pb_import_issues_tenants_tenant_id");
b.HasOne("Tiku.Domain.Import.PocketBaseImportRun", null)
.WithMany()
.HasForeignKey("TenantId", "RunId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_pb_import_issues_pb_import_runs_tenant_id_run_id");
});
modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportRun", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_pb_import_runs_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Import.PocketBaseRawRecord", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_pb_raw_records_tenants_tenant_id");
b.HasOne("Tiku.Domain.Import.PocketBaseImportRun", null)
.WithMany()
.HasForeignKey("TenantId", "RunId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_pb_raw_records_pb_import_runs_tenant_id_run_id");
});
modelBuilder.Entity("Tiku.Domain.Learning.AnswerRecord", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -5,6 +5,7 @@ using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Growth;
using Tiku.Domain.Identity;
using Tiku.Domain.Import;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
@@ -136,6 +137,9 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
public DbSet<PlatformAuditAlert> PlatformAuditAlerts => Set<PlatformAuditAlert>();
public DbSet<PlatformDunningNotificationChannel> PlatformDunningNotificationChannels => Set<PlatformDunningNotificationChannel>();
public DbSet<PlatformDunningNotificationEvent> PlatformDunningNotificationEvents => Set<PlatformDunningNotificationEvent>();
public DbSet<PocketBaseImportRun> PocketBaseImportRuns => Set<PocketBaseImportRun>();
public DbSet<PocketBaseRawRecord> PocketBaseRawRecords => Set<PocketBaseRawRecord>();
public DbSet<PocketBaseImportIssue> PocketBaseImportIssues => Set<PocketBaseImportIssue>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View File

@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Metadata;
using Tiku.Domain.Commerce;
using Tiku.Domain.Content;
using Tiku.Domain.Growth;
using Tiku.Domain.Import;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
@@ -29,7 +30,7 @@ public sealed class PersistenceModelTests
.Select(entity => entity.GetTableName())
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(121, tableNames.Count);
Assert.Equal(124, tableNames.Count);
Assert.Contains("tenants", tableNames);
Assert.Contains("tenant_settings", tableNames);
Assert.Contains("content_entries", tableNames);
@@ -129,6 +130,9 @@ public sealed class PersistenceModelTests
Assert.Contains("platform_audit_alerts", tableNames);
Assert.Contains("platform_dunning_notification_channels", tableNames);
Assert.Contains("platform_dunning_notification_events", tableNames);
Assert.Contains("pb_import_runs", tableNames);
Assert.Contains("pb_raw_records", tableNames);
Assert.Contains("pb_import_issues", tableNames);
Assert.Contains("questions", tableNames);
Assert.Contains("question_versions", tableNames);
Assert.Contains("answer_records", tableNames);
@@ -807,4 +811,43 @@ public sealed class PersistenceModelTests
.SequenceEqual(propertyNames));
}
}
[Theory]
[InlineData(typeof(PocketBaseImportRun), nameof(PocketBaseImportRun.Stats), "'{}'::jsonb")]
[InlineData(typeof(PocketBaseRawRecord), nameof(PocketBaseRawRecord.Record), "'{}'::jsonb")]
[InlineData(typeof(PocketBaseRawRecord), nameof(PocketBaseRawRecord.Errors), "'[]'::jsonb")]
public void PocketBase_import_json_properties_are_mapped_to_jsonb(
Type entityType,
string propertyName,
string expectedDefaultValueSql)
{
using var context = new TikuDbContext(Options);
var property = context.Model.FindEntityType(entityType)!
.FindProperty(propertyName)!;
Assert.Equal("jsonb", property.GetColumnType());
Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
}
[Fact]
public void PocketBase_import_raw_records_keep_legacy_identity_in_primary_key()
{
using var context = new TikuDbContext(Options);
var keyProperties = context.Model.FindEntityType(typeof(PocketBaseRawRecord))!
.FindPrimaryKey()!
.Properties
.Select(property => property.Name)
.ToArray();
Assert.Equal(
[
nameof(PocketBaseRawRecord.RunId),
nameof(PocketBaseRawRecord.CollectionName),
nameof(PocketBaseRawRecord.LegacyId)
],
keyProperties);
}
}