Skip to content

feat(persistence): add Microsoft SQL Server as an opt-in database provider - #1374

Open
maxiar wants to merge 1 commit into
fullstackhero:mainfrom
maxiar:feat/mssql-provider-support-upstream
Open

feat(persistence): add Microsoft SQL Server as an opt-in database provider#1374
maxiar wants to merge 1 commit into
fullstackhero:mainfrom
maxiar:feat/mssql-provider-support-upstream

Conversation

@maxiar

@maxiar maxiar commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Adds Microsoft SQL Server as a second, config-selected database provider. DatabaseOptions:Provider switches the whole stack — there are no hardcoded provider strings left. Both migrations assemblies ship in every build, so moving between engines is a config change, not a rescaffold.

Requires SQL Server 2025 (17.x) or Azure SQL. JSON columns map to the native json type at compatibility level 170, which does not exist on 2019/2022.

The provider seam

Entity configurations stop naming Postgres types and declare intent instead; HeroProviderConventions resolves it per provider:

Instead of Write PostgreSQL SQL Server
HasColumnType("jsonb") .HasJsonColumn() jsonb json
HasColumnType("text") .HasUnboundedTextColumn() text nvarchar(max)
HasFilter("\"IsDeleted\" = FALSE") .HasNotDeletedFilter() "IsDeleted" = FALSE [IsDeleted] = 0
.HasMethod("gin").HasOperators(…) .AsTrigramSearchIndex() GIN index removed from model

It runs as an EF model-finalizing convention, so it applies regardless of where a subclass calls base.OnModelCreating. Queries use WhereSearch(...) instead of the 16 Npgsql-only EF.Functions.ILike call sites.

Also provider-aware now: the outbox claim (UPDLOCK, READPAST, OUTPUT — a real SKIP LOCKED equivalent, replacing the degraded single-dispatcher fallback), Hangfire stale-lock cleanup, OpenTelemetry instrumentation, and the DbMigrator lock (sp_getapplock).

Aspire (DbProvider=MSSQL), Docker Compose (--profile mssql), Testcontainers (FSH_TEST_DB_PROVIDER=MSSQL) and fsh new --db-provider mssql all understand the choice.

Four defects found on the way — three pre-existing, all masked by PostgreSQL

  1. The transactional outbox was never transactional. AmbientDbTransactionRegistry's methods did not match IDbTransactionInterceptor; the interface's default no-op implementations bound instead, so the registry stayed permanently empty and the outbox never enlisted in the business transaction. Npgsql hides this by associating commands with the connection's open transaction — SqlClient throws. Fixed, plus a reflection test that fails if a signature drifts again.
  2. Guid v7 does not sort chronologically on SQL Server. uniqueidentifier compares the last six bytes first, so chat cursor pagination returned wrongly-ordered pages, silently. Reproduced in a container (ORDER BY Id gave t3 t2 t1 instead of t1 t2 t3) and fixed with a persisted char(36) sort key.
  3. EnableRetryOnFailure on the MSSQL branch would have broken every transactional publish — its execution strategy refuses user-initiated transactions.
  4. appsettings.Production.json was missing DatabaseOptions:MigrationsAssembly; ConnectionStringValidator reported success for unknown providers.

Capability differences on SQL Server

  • Chat search uses FREETEXTTABLE where Full-Text Search is installed, UNIONed with a LIKE pass over the last few minutes — SQL Server populates a full-text index asynchronously, so without it a just-sent message would be unsearchable. Falls back to a plain LIKE scan where FTS is absent (the official container has none; Azure SQL and full installs do).
  • Audit Source/UserName substring search scans — no trigram equivalent on nvarchar(max).
  • The audit payload keeps an index either way: jsonb_path_ops GIN on Postgres, CREATE JSON INDEX on SQL Server.

Guard against one-sided migrations

MigrationDriftTests compares each context's live model against that provider's own snapshot, so a change that only gets one provider's migration fails the build with the exact migrations add command. Maintaining both is not mandatory: FshMaintainedDbProviders in Directory.Build.props decides which providers are enforced and which only report, so a project that settles on one engine is never blocked by the other.

Verification

Check PostgreSQL SQL Server 2025
Integration suite 746 passed (1 skip) 746 passed (1 skip)
Model drift, 11 contexts clean clean
DbMigrator from an empty server creates DB, 13 migrations, seeds, exit 0
Chat search with real FTS 4/4
Unit suites ~1,070 passed

The drift guard was also proven to fail: adding a property with only the PostgreSQL migration generated leaves the build red for MSSQL alone.

PostgreSQL is unchanged — all 11 DbContexts remain drift-free against the existing migrations.

One unrelated change, and why it is here

Testcontainers is bumped 4.11.0 → 4.14.0. The 4.11.0 line drags in SSH.NET 2025.1.0, which carries a high-severity advisory (GHSA-q939-rpr3-3284) and fails NuGetAudit under TreatWarningsAsErrorsmain does not currently build because of it, verified on a clean checkout with none of this branch applied. Since this PR adds a Testcontainers package of its own, leaving the family split was not an option. Happy to split it out if you would rather take it separately.

Follow-up

Per golden rule #10, the docs site (fullstackhero/docs) needs a companion PR: a database-providers page and a changelog entry. Content is drafted; say the word and I will open it.


Branched from main, not from my fork's develop, so it carries only this feature and none of my fork-local commits.

…vider

The stack now runs on PostgreSQL or SQL Server, selected entirely by
`DatabaseOptions:Provider` — no hardcoded provider strings remain. Both
migrations assemblies ship in every build, so switching is a config change
rather than a rescaffold.

Requires SQL Server 2025 (17.x) or Azure SQL: JSON columns map to the native
`json` type at compatibility level 170, which does not exist on 2019/2022.

Provider seam
- Entity configurations now declare portable intent (`HasJsonColumn`,
  `HasNotDeletedFilter`, `AsTrigramSearchIndex`, …) which `HeroProviderConventions`
  resolves per provider. Implemented as an EF model-finalizing convention so it
  runs regardless of where a subclass calls `base.OnModelCreating`.
- `WhereSearch` replaces the 16 Npgsql-only `EF.Functions.ILike` call sites.
- MSSQL outbox claim via `UPDLOCK, READPAST, OUTPUT` — a real equivalent of
  `FOR UPDATE SKIP LOCKED`, not the previous degraded single-dispatcher fallback.
- Hangfire stale-lock cleanup, OpenTelemetry instrumentation and the DbMigrator
  lock (`sp_getapplock`) all gained SQL Server paths.

New `FSH.Starter.Migrations.MSSQL` with one consolidated `Initial` per DbContext,
plus hand-written DDL EF has no API for: `CREATE JSON INDEX` on audit payloads and
the chat full-text catalog.

Aspire (`DbProvider=MSSQL`), Docker Compose (`--profile mssql`), Testcontainers
(`FSH_TEST_DB_PROVIDER=MSSQL`) and `fsh new --db-provider mssql` all understand the
choice.

Fixes found along the way, all masked by PostgreSQL
- `AmbientDbTransactionRegistry` never ran: its methods did not match
  `IDbTransactionInterceptor`, whose default no-op implementations bound instead.
  The outbox therefore never enlisted in the business transaction. Npgsql hides
  this by associating commands with the connection's open transaction; SqlClient
  throws. Guarded by a new reflection test.
- Guid v7 ids do not sort chronologically on SQL Server (`uniqueidentifier`
  compares the last six bytes first), which silently broke chat cursor pagination.
  Message paging now sorts on a persisted `char(36)` key.
- `EnableRetryOnFailure` on the MSSQL branch would have broken every transactional
  publish; its execution strategy refuses user-initiated transactions.
- `appsettings.Production.json` was missing `DatabaseOptions:MigrationsAssembly`.
- `ConnectionStringValidator` reported success for unknown providers.

Testcontainers is bumped 4.11.0 -> 4.14.0 because the older line drags in SSH.NET
2025.1.0 (GHSA-q939-rpr3-3284) and fails NuGetAudit under TreatWarningsAsErrors —
main does not currently build without this.

`MigrationDriftTests` compares each context's live model against that provider's
own snapshot, so a change that only gets one provider's migration fails the build.
Maintaining both is not mandatory: `FshMaintainedDbProviders` decides which
providers are enforced and which only report.

PostgreSQL is unchanged: all 11 DbContexts remain drift-free against the existing
migrations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxiar

maxiar commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up for sequencing: #1376 (a database-backed Data Protection key store) depends on this PR and cannot merge before it.

Two concrete things it takes from here:

  • MigratorLockFactory / IMigratorLock, for a step that must run before the DbMigrator starts its host.
  • src/Host/FSH.Starter.Migrations.MSSQL, which three of its migration files live in — they are inert until this PR creates that project.

Nothing is needed from you here; flagging it so the ordering is visible rather than discovered when #1376 fails to build.

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.

1 participant