Skip to content

Apply base entity inheritance consistently across all modules#102

Merged
antosubash merged 5 commits intomainfrom
claude/fix-base-entity-usage-eIyPh
Apr 11, 2026
Merged

Apply base entity inheritance consistently across all modules#102
antosubash merged 5 commits intomainfrom
claude/fix-base-entity-usage-eIyPh

Conversation

@antosubash
Copy link
Copy Markdown
Owner

Replace ad-hoc timestamp/audit fields with inheritance from the Core
base entities (Entity, AuditableEntity, FullAuditableEntity).
This brings automatic CreatedAt/UpdatedAt tracking, concurrency stamps,
versioning, and soft-delete semantics to modules that were previously
rolling their own or missing them entirely.

Changes:

  • BackgroundJobs: JobProgress and JobQueueEntryEntity now extend
    Entity.
  • Settings: SettingEntity and PublicMenuItemEntity now extend
    Entity; services drop manual UpdatedAt assignments.
  • Chat: Conversation extends Entity, ChatMessage
    extends Entity; UpdatedAt is maintained on
    append/rename.
  • FileStorage: StoredFile extends Entity.
  • Email: EmailMessage and EmailTemplate extend Entity /
    AuditableEntity; DateTime fields migrated to DateTimeOffset
    and query filters updated accordingly.
  • Orders: Order extends AuditableEntity with DateTimeOffset
    timestamps; seed service updated to emit DateTimeOffset values.
  • PageBuilder: Page uses FullAuditableEntity (soft delete via
    IsDeleted/DeletedAt), PageTemplate uses Entity,
    PageSummary DTO switched to DateTimeOffset; permanent-delete path
    uses ExecuteDeleteAsync to bypass the soft-delete interceptor.
  • Products: Product extends Entity with deterministic
    seed timestamps so EF Core migrations remain stable.
  • RateLimiting: RateLimitRule extends Entity.
  • Agents: AgentSession gains inherited audit fields via Entity.

SQLite support: modules that now carry DateTimeOffset properties and
run queries against SQLite (tests, local dev) apply
DateTimeOffsetToBinaryConverter conditionally so ORDER BY and range
filters translate. A new DatabaseOptionsExtensions.DetectProvider()
helper centralizes the provider detection for this decision, falling
back gracefully when only a module-specific connection string is
configured.

A single EF Core migration (UseBaseEntities) adds the new columns
(ConcurrencyStamp, CreatedAt/UpdatedAt, soft-delete fields, version)
and adjusts the existing DateTime columns where modules moved to
DateTimeOffset.

claude added 4 commits April 11, 2026 06:29
Replace ad-hoc timestamp/audit fields with inheritance from the Core
base entities (Entity<T>, AuditableEntity<T>, FullAuditableEntity<T>).
This brings automatic CreatedAt/UpdatedAt tracking, concurrency stamps,
versioning, and soft-delete semantics to modules that were previously
rolling their own or missing them entirely.

Changes:
- BackgroundJobs: JobProgress and JobQueueEntryEntity now extend
  Entity<Guid>.
- Settings: SettingEntity and PublicMenuItemEntity now extend
  Entity<int>; services drop manual UpdatedAt assignments.
- Chat: Conversation extends Entity<ConversationId>, ChatMessage
  extends Entity<ChatMessageId>; UpdatedAt is maintained on
  append/rename.
- FileStorage: StoredFile extends Entity<FileStorageId>.
- Email: EmailMessage and EmailTemplate extend Entity<TId> /
  AuditableEntity<TId>; DateTime fields migrated to DateTimeOffset
  and query filters updated accordingly.
- Orders: Order extends AuditableEntity<OrderId> with DateTimeOffset
  timestamps; seed service updated to emit DateTimeOffset values.
- PageBuilder: Page uses FullAuditableEntity<PageId> (soft delete via
  IsDeleted/DeletedAt), PageTemplate uses Entity<PageTemplateId>,
  PageSummary DTO switched to DateTimeOffset; permanent-delete path
  uses ExecuteDeleteAsync to bypass the soft-delete interceptor.
- Products: Product extends Entity<ProductId> with deterministic
  seed timestamps so EF Core migrations remain stable.
- RateLimiting: RateLimitRule extends Entity<RateLimitRuleId>.
- Agents: AgentSession gains inherited audit fields via Entity<string>.

SQLite support: modules that now carry DateTimeOffset properties and
run queries against SQLite (tests, local dev) apply
DateTimeOffsetToBinaryConverter conditionally so ORDER BY and range
filters translate. A new DatabaseOptionsExtensions.DetectProvider()
helper centralizes the provider detection for this decision, falling
back gracefully when only a module-specific connection string is
configured.

A single EF Core migration (UseBaseEntities) adds the new columns
(ConcurrencyStamp, CreatedAt/UpdatedAt, soft-delete fields, version)
and adjusts the existing DateTime columns where modules moved to
DateTimeOffset.
Every remaining entity that still used plain int/Guid/string IDs now
uses a Vogen value object, matching the rest of the codebase. This
gives the entity layer the same type safety the contracts layer
already has and removes the last few mismatched ID types across
modules.

New Vogen IDs (added to each module's Contracts project):
- Settings.Contracts: SettingId, PublicMenuItemId (int-backed)
- FeatureFlags.Contracts: FeatureFlagId, FeatureFlagOverrideId (int-backed)
- Agents.Contracts: AgentSessionId, AgentMessageId (string-backed)

Reused existing Vogen IDs:
- BackgroundJobs.Contracts.JobId for both JobProgress and
  JobQueueEntryEntity (they share the logical job identifier)

Entity/service changes:
- Entities switched to Entity<TId> with the new strongly-typed IDs.
- DbContexts register EfCoreValueConverter/Comparer conventions for
  each new ID type.
- Services and endpoints accept/return the typed IDs, with the
  natural conversion boundaries (route-bound int -> Vogen.From(int),
  JobQueueEntry DTO Guid <-> JobId.From(guid)) happening where
  infrastructure types cross.
- Internal stores (InMemoryAgentSessionStore, EfAgentSessionStore)
  key their dictionaries/queries on AgentSessionId while keeping the
  public IAgentSessionStore string-based API unchanged.

Contracts DTO updates:
- FeatureFlagOverride.Id, PublicMenuItemDto.Id/ParentId,
  CreateMenuItemRequest.ParentId, ReorderItem.Id/ParentId now carry
  the typed IDs end to end.
- IFeatureFlagContracts.DeleteOverrideAsync takes
  FeatureFlagOverrideId; endpoint converts the route int.

Unit tests updated for the new ID types (FindAsync calls, seeded
entity initializers, service-level invocations). A fresh EF Core
migration replaces the previous UseBaseEntities migration and adds
no schema changes (Vogen converters preserve the underlying
column types) but updates the model snapshot to record the new
value-converter wiring.
Moves every EF Core entity that was still living in an implementation
project into its module's `.Contracts` assembly, and adds a
Roslyn-powered compile-time diagnostic (SM0055) so the rule holds
going forward.

Entities relocated:
- BackgroundJobs: JobProgress, JobQueueEntryEntity
- Settings: SettingEntity, PublicMenuItemEntity
- FeatureFlags: FeatureFlagEntity, FeatureFlagOverrideEntity
- Agents: AgentSession, AgentMessage
- Datasets: Dataset
- Tenants: TenantEntity, TenantHostEntity
- Permissions: RolePermission, UserPermission (junction tables)

Each moved entity is tagged with `[NoDtoGeneration]` where it isn't
a true DTO, so the TypeScript generator still only emits types the
frontend actually consumes. `IEntityTypeConfiguration<T>` mappings,
the `DbContext` itself, services, and endpoints stay where they
were — only the entity class crosses the assembly boundary.

Supporting changes:
- Permissions.Contracts now references EntityFrameworkCore (needed
  so the relocated junction types can sit next to the existing
  contract interfaces).
- FeatureFlags entity configurations moved from the old `Entities/`
  folder to a dedicated `EntityConfigurations/` folder, matching the
  convention used by the other modules.
- Settings/Agents/Permissions/Tenants/Datasets services updated to
  import from `*.Contracts` instead of the deleted `*.Entities`
  namespaces.

New diagnostic SM0055:
- Fires on every `DbSet<T>` whose entity type is source-declared in
  a `SimpleModule.*` assembly that isn't a `.Contracts` assembly.
- Skips Identity/OpenIddict entities (external metadata-only types
  the author can't move).
- Suggests the correct replacement assembly, including the special
  case where the implementation is suffixed with `.Module`
  (SimpleModule.Agents.Module → SimpleModule.Agents.Contracts).
- Registered in `AnalyzerReleases.Unshipped.md`.

Generator plumbing:
- `DbSetInfoRecord` now carries the entity's containing assembly
  name and source location so the diagnostic can pinpoint the
  offending type.
- Discovery walks the `DbSet<T>` type argument and records those
  fields alongside the existing property name + FQN.

Tests:
- Four new generator tests cover SM0055: offending implementation
  assembly, compliant contracts assembly, the `.Module`-suffixed
  sibling case, and non-`SimpleModule.*` external assemblies.
- `GeneratorTestHelper` gains
  `CreateEfCoreCompilationWithAssemblyName()` so tests can simulate
  the assembly name the diagnostic inspects.
- All 193 Generator.Tests pass; affected module test suites
  (Settings, FeatureFlags, BackgroundJobs, Tenants, Permissions,
  Datasets) pass cleanly.

Constitution:
- Section 2 "What a Module Must Always Expose" now lists entity
  classes as required Contracts content (with the
  `[NoDtoGeneration]` guidance).
- Section 4 "Data Ownership" inverted: entities live in Contracts;
  configurations, DbContext, and services stay in implementation.
- Compiler-enforced rules table updated with SM0055.
Code-review pass on the SM0055 commit turned up a few small wins:

- Introduce `AssemblyConventions` (FrameworkPrefix, ContractsSuffix,
  ModuleSuffix, GetExpectedContractsAssemblyName). The SM0055 emitter
  was hardcoding `"SimpleModule."`, `".Contracts"`, and `".Module"`
  as literals and open-coding the `.Module` → `.Contracts` suffix
  swap. Other diagnostics in the same file also reach for the same
  literals, so having a named home for them makes future renames
  trivial.
- Fuse the SM0055 and SM0006 iterations over `data.DbContexts` /
  `DbSets`. They previously walked the exact same nested loop
  back-to-back; now the first pass builds `allEntityFqns` as a side
  effect and the SM0006 loop only consumes it.
- Drop the redundant `string.IsNullOrEmpty(EntityAssemblyName)` guard
  in SM0055 — `EntityLocation is null` already covers the
  metadata-only / missing-symbol case, and the guards now read as one
  coherent "skip anything we can't flag" block.
- Delete the `CreateCompilationWithAssemblyName` /
  `CreateEfCoreCompilationWithAssemblyName` test helpers. The
  unparameterised variant was never called, and the EfCore variant
  was a one-line `.WithAssemblyName(...)` wrapper. Inline the call
  at the four SM0055 test sites so the Roslyn API is visible and
  the test helper's surface stops growing.
- Document `TenantEntity.ConnectionString` as infrastructure-only.
  The field is now visible through `Tenants.Contracts`, so flag the
  intent in a comment — other modules must still go through
  `ITenantsContracts`, never read the raw string.

All 193 generator tests continue to pass. Full solution build is
clean.
@antosubash antosubash enabled auto-merge (squash) April 11, 2026 13:49
@antosubash antosubash disabled auto-merge April 11, 2026 13:49
@antosubash antosubash enabled auto-merge (squash) April 11, 2026 13:50
The CI build job failed on
`DatabaseJobQueueTests.DequeueAsync_ReturnsAndClaimsOldestPending`
with `System.InvalidCastException: Object must implement IConvertible`.

The test does:
    var row = await _db.JobQueueEntries.SingleAsync(e => e.Id == claimed.Id);

After the Vogen-ID migration, `e.Id` is `JobId` (Vogen struct wrapping
Guid) but `claimed.Id` is still a plain `Guid` on the `JobQueueEntry`
transport record. When EF Core builds the query parameter it runs the
`JobId.EfCoreValueConverter` sanitiser on the raw `Guid`, which ends
up calling `Convert.ChangeType(guid, typeof(JobId))` — and Vogen
value objects don't implement `IConvertible`, so the call throws.

Fix: wrap `claimed.Id` in `JobId.From(...)` at the test site so both
sides of the comparison are already `JobId`. EF Core's SQL
translator then converts the typed parameter through the normal
`JobId → Guid` path without touching the sanitiser.

All 97 BackgroundJobs.Tests pass locally with the fix.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying simplemodule-website with  Cloudflare Pages  Cloudflare Pages

Latest commit: 096dd75
Status: ✅  Deploy successful!
Preview URL: https://90342b19.simplemodule-website.pages.dev
Branch Preview URL: https://claude-fix-base-entity-usage.simplemodule-website.pages.dev

View logs

@antosubash antosubash merged commit 46f1747 into main Apr 11, 2026
5 checks passed
@antosubash antosubash deleted the claude/fix-base-entity-usage-eIyPh branch April 11, 2026 14:10
antosubash pushed a commit that referenced this pull request Apr 11, 2026
PR #102 moved the Dataset entity out of SimpleModule.Datasets.Entities
and into SimpleModule.Datasets.Contracts alongside the other module
entities. The merge into this branch broke CS0234 on the now-missing
namespace import in my test file; drop the using and rely on the
existing SimpleModule.Datasets.Contracts import for the Dataset type.
antosubash added a commit that referenced this pull request Apr 11, 2026
* Purge dataset storage blobs in background job on delete

DeleteAsync previously soft-deleted the row but never removed the
original upload, normalized GeoJSON cache, or any derivative blobs —
so every deleted dataset leaked its entire storage footprint (often
GBs). Add PurgeDatasetJob that runs after the soft-delete and walks
the metadata to delete every referenced blob off the critical path.

* Install native GIS tooling in runtime image for Datasets module

The Datasets module's background jobs shell out to native GIS tools:
gdal_translate/gdalwarp/ogr2ogr for rasters + non-GeoJSON vector
formats, SpatiaLite for GeoPackage reads, and tippecanoe for
vector→PMTiles. None of these ship with the dotnet/aspnet base image.

- Runtime stage now installs gdal-bin, libsqlite3-mod-spatialite, unzip
- New tippecanoe-builder stage compiles felt/tippecanoe from source
  (not in Debian repos) and the runtime copies the 6 installed binaries
  from /opt/tippecanoe/bin/

Adds ~300 MB to the runtime image (GDAL pulls libproj/libgeos/libnetcdf)
but is unavoidable for real GIS support.

* Move GIS tooling from Host Dockerfile to new Worker Dockerfile

Split concerns so only the process that actually runs IModuleJob handlers
carries the ~300 MB of native GIS dependencies. The Host runs in
BackgroundJobs:WorkerMode=Producer and only enqueues jobs, so gdal-bin,
SpatiaLite, and tippecanoe have no business being on its image.

- Dockerfile: reverted to its original state (no GIS apt installs, no
  tippecanoe-builder stage)
- Dockerfile.worker: new, builds template/SimpleModule.Worker with its
  own tippecanoe-builder stage, gdal-bin / libsqlite3-mod-spatialite /
  unzip in the runtime, and dotnet/runtime:10.0 base (no ASP.NET stack
  needed since the Worker is a Generic Host). Installs Node in the
  build stage because the ExtractDtoTypeScript / ExtractRoutes MSBuild
  targets in SimpleModule.Hosting.targets shell out to `node tools/*.mjs`
  after CoreCompile.
- SimpleModule.Worker.csproj: add reference to SimpleModule.Datasets so
  the source generator registers ProcessDatasetJob / ConvertDatasetJob /
  PurgeDatasetJob for the consumer to execute.
- docker-compose.yml: add worker service built from Dockerfile.worker,
  pin api service to WorkerMode=Producer for explicitness, introduce
  a shared storage_data named volume so uploaded dataset blobs written
  by the Host are visible to the Worker when the background job picks
  them up.

Validated Worker publish against a git-archive-clean source tree
(mirroring the Docker build context with no node_modules, no wwwroot) —
builds clean with only the expected "node_modules not found" warnings
and produces SimpleModule.{Worker,Datasets,BackgroundJobs,Email}.dll.

* Simplify PurgeDatasetJob and trim verbose docker comments

- PurgeDatasetJob: add .AsNoTracking() on the row load (read-only), drop
  the duplicate DeserializeMetadata helper in favour of inline
  JsonSerializer.Deserialize, collect blob paths up front and parallelize
  deletes with Task.WhenAll instead of awaiting each one sequentially.
  Removes the mutable deleted counter in the process.
- DatasetsContractsService.DeleteAsync: drop the narrative comment above
  the EnqueueAsync call — the PurgeDatasetJob summary already explains why.
- Dockerfile.worker: drop per-stage banner comments that just restate the
  FROM ... AS label. Keep the top-of-file block and the tippecanoe
  source-build rationale.
- docker-compose.yml: drop the apologetic "set here for visibility"
  comment on the worker's BackgroundJobs__WorkerMode env var.

* Drop stale Datasets.Entities import in PurgeDatasetJobTests

PR #102 moved the Dataset entity out of SimpleModule.Datasets.Entities
and into SimpleModule.Datasets.Contracts alongside the other module
entities. The merge into this branch broke CS0234 on the now-missing
namespace import in my test file; drop the using and rely on the
existing SimpleModule.Datasets.Contracts import for the Dataset type.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants