mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add new documentation for Dapper migration/database schema
This commit is contained in:
@@ -4,7 +4,7 @@ The Biergarten App is a full-stack directory and discovery platform for
|
|||||||
breweries. It provides:
|
breweries. It provides:
|
||||||
|
|
||||||
- JWT-based user authentication, with email confirmation and refresh-token
|
- JWT-based user authentication, with email confirmation and refresh-token
|
||||||
rotation
|
reissue
|
||||||
- A searchable database of brewery locations
|
- A searchable database of brewery locations
|
||||||
- An offline data-generation pipeline that uses large language models (LLMs)
|
- An offline data-generation pipeline that uses large language models (LLMs)
|
||||||
via Llama.cpp and Wikipedia to synthesize realistic seed data
|
via Llama.cpp and Wikipedia to synthesize realistic seed data
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ For visual representations, see:
|
|||||||
|
|
||||||
The backend organizes business capabilities as feature slices instead of
|
The backend organizes business capabilities as feature slices instead of
|
||||||
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
||||||
`Features.UserManagement`, `Features.Emails`) is a single project that owns its
|
`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single
|
||||||
own controller, MediatR commands/queries/handlers, validators, and repository,
|
project that owns its own MediatR commands/queries/handlers, validators, and
|
||||||
end to end:
|
repository end to end. `Features.Emails` and `Features.Locations` have no
|
||||||
|
`Controllers/` folder — both are invoked internally by other slices via
|
||||||
|
MediatR/DI, never over HTTP:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────────────────────────────────────────────────┐
|
┌───────────────────────────────────────────────────────────────────────┐
|
||||||
@@ -46,15 +48,15 @@ end to end:
|
|||||||
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
||||||
└───────────────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────────────┘
|
||||||
↓ discovers controllers via AddApplicationPart
|
↓ discovers controllers via AddApplicationPart
|
||||||
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
|
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐
|
||||||
│ Features.Auth │Features. │ Features. │ Features.Emails │
|
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │
|
||||||
│ │Breweries │ UserManagement │ (no controller, │
|
│ │Breweries │ UserManagement │ (no controller, │ Locations │
|
||||||
│ Controller │ Controller │ Controller │ internal only) │
|
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │
|
||||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
|
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │
|
||||||
│ Queries + │ Queries + │ Queries + │ Handlers │
|
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │
|
||||||
│ Handlers │ Handlers │ Handlers │ │
|
│ Handlers │ Handlers │ Handlers │ │ │
|
||||||
│ Repository │ Repository │ Repository │ EmailDispatcher │
|
│ Repository │ Repository │ Repository │ EmailDispatcher │ │
|
||||||
└───────────────┴───────────────┴───────────────────┴───────────────────┘
|
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
|
||||||
↓ each slice depends only on shared/domain/infra, never on another slice
|
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||||
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||||
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||||
@@ -108,7 +110,7 @@ own
|
|||||||
- No controllers, no business logic, no feature-specific contracts
|
- No controllers, no business logic, no feature-specific contracts
|
||||||
- Exists purely to host and wire up the feature slices
|
- Exists purely to host and wire up the feature slices
|
||||||
|
|
||||||
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
|
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
|
||||||
|
|
||||||
**Purpose**: Each slice is the complete vertical for one business capability
|
**Purpose**: Each slice is the complete vertical for one business capability
|
||||||
|
|
||||||
@@ -126,7 +128,12 @@ own
|
|||||||
slice's repository/services
|
slice's repository/services
|
||||||
|
|
||||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
||||||
commands sent from other slices, never over HTTP.
|
commands sent from other slices, never over HTTP. `Features.Locations` also has
|
||||||
|
no `Controllers/` folder and no MediatR handlers — it exposes
|
||||||
|
`ILocationRepository` directly as a plain service, currently consumed only by
|
||||||
|
`Database.Seed` to look up cities while seeding brewery locations. Other slices
|
||||||
|
(for example, `Features.Breweries`) do not yet depend on it; they run their own
|
||||||
|
`CityID` existence checks inline.
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
@@ -198,6 +205,9 @@ whichever slices need them
|
|||||||
- `UserAccount` - User profile data
|
- `UserAccount` - User profile data
|
||||||
- `UserCredential` - Authentication credentials
|
- `UserCredential` - Authentication credentials
|
||||||
- `UserVerification` - Account verification state
|
- `UserVerification` - Account verification state
|
||||||
|
- `BreweryPost` - A user-submitted brewery listing
|
||||||
|
- `BreweryPostLocation` - A brewery's address and coordinates
|
||||||
|
- `City` - A city referenced by a brewery location
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
@@ -258,7 +268,6 @@ only:
|
|||||||
**Benefits**:
|
**Benefits**:
|
||||||
|
|
||||||
- Testable (easy to mock)
|
- Testable (easy to mock)
|
||||||
- SQL-first approach (stored procedures)
|
|
||||||
- Each slice's data access logic is self-contained
|
- Each slice's data access logic is self-contained
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
@@ -285,22 +294,31 @@ method that registers its repository and slice-internal services
|
|||||||
- Singleton: `ISqlConnectionFactory`
|
- Singleton: `ISqlConnectionFactory`
|
||||||
- Transient: Utilities, helpers
|
- Transient: Utilities, helpers
|
||||||
|
|
||||||
#### SQL-first approach
|
#### Direct SQL via Dapper/ADO.NET
|
||||||
|
|
||||||
**Purpose**: Push complex logic into the database
|
**Purpose**: Keep data access explicit and colocated with the slice that owns
|
||||||
|
it, without an ORM's change-tracking or mapping magic
|
||||||
|
|
||||||
**Strategy**:
|
**Strategy**:
|
||||||
|
|
||||||
- All queries via stored procedures
|
- No ORM (Entity Framework not used); each
|
||||||
- No ORM (Entity Framework not used)
|
repository issues inline SQL (a mix of Dapper and raw
|
||||||
- Database handles complex logic
|
`DbCommand`/`DbDataReader` for the few reads that need manual column mapping,
|
||||||
- Application focuses on orchestration
|
such as the `GEOGRAPHY` column on `BreweryPostLocation`)
|
||||||
|
- Referential checks that a stored procedure used to perform (e.g. "does this
|
||||||
|
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
||||||
|
repository method, run inside the same transaction as the write
|
||||||
|
- Optimistic concurrency uses each table's `Timer` (`ROWVERSION`) column,
|
||||||
|
checked in the `UPDATE ... WHERE ... AND Timer = @Timer` clause
|
||||||
|
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
|
||||||
|
`ConflictException`, ...) directly when a check fails, rather than relying on
|
||||||
|
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
|
||||||
|
exception types to HTTP status codes
|
||||||
|
- Application focuses on orchestration; the database enforces integrity via
|
||||||
|
keys, `CHECK` constraints, and cascades
|
||||||
|
|
||||||
**Stored Procedure Examples**:
|
See [Database](website/database.md) for the schema and the app's current SQL
|
||||||
|
error-handling approach in more detail.
|
||||||
- `USP_RegisterUser` - User registration
|
|
||||||
- `USP_GetUserAccountByUsername` - User lookup
|
|
||||||
- `USP_RotateUserCredential` - Password update
|
|
||||||
|
|
||||||
## Frontend architecture
|
## Frontend architecture
|
||||||
|
|
||||||
@@ -357,18 +375,29 @@ for reference only. Active product and engineering documentation should point to
|
|||||||
1. **Registration**:
|
1. **Registration**:
|
||||||
- User submits credentials
|
- User submits credentials
|
||||||
- Password hashed with Argon2id
|
- Password hashed with Argon2id
|
||||||
- User account created
|
- User account created (unverified) and a confirmation email is dispatched
|
||||||
- JWT token issued
|
via `Features.Emails`
|
||||||
|
|
||||||
2. **Login**:
|
2. **Email confirmation**:
|
||||||
|
- User follows the confirmation link/token from the email
|
||||||
|
- `Features.Auth`'s `ConfirmUser` command marks the account verified
|
||||||
|
|
||||||
|
3. **Login**:
|
||||||
- User submits credentials
|
- User submits credentials
|
||||||
- Password verified against hash
|
- Password verified against hash
|
||||||
- JWT token issued
|
- Access and refresh JWTs are issued; the refresh token is stored server-side
|
||||||
- Token stored client-side
|
against the user's credential row
|
||||||
|
|
||||||
3. **API Requests**:
|
4. **Token refresh**:
|
||||||
- Client sends JWT in Authorization header
|
- Client exchanges a valid, unexpired refresh token for a new access/refresh
|
||||||
- Middleware validates token
|
pair via `Features.Auth`'s `RefreshToken` command
|
||||||
|
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
|
||||||
|
refresh token is not invalidated and remains usable until its own
|
||||||
|
expiration
|
||||||
|
|
||||||
|
5. **API Requests**:
|
||||||
|
- Client sends the access JWT in the `Authorization` header
|
||||||
|
- `JwtAuthenticationHandler` validates the token
|
||||||
- Request proceeds if valid
|
- Request proceeds if valid
|
||||||
|
|
||||||
### Password security
|
### Password security
|
||||||
@@ -433,32 +462,40 @@ scheme, see [Database](website/database.md).
|
|||||||
|
|
||||||
**Process**:
|
**Process**:
|
||||||
|
|
||||||
1. Write SQL migration script
|
1. Write a SQL script under `Database.Migrations/scripts/`
|
||||||
2. Embed in `Database.Migrations` project
|
2. Embed it in the `Database.Migrations` project
|
||||||
3. Run migrations on startup
|
3. `Database.Migrations` runs each script against the target database on
|
||||||
4. Idempotent and versioned
|
startup, tracked so a script never re-runs once applied
|
||||||
|
|
||||||
**Migration Files**:
|
**Migration Files**:
|
||||||
|
|
||||||
```
|
```
|
||||||
scripts/
|
scripts/
|
||||||
├── 001-CreateUserTables.sql
|
└── 01-schema/
|
||||||
├── 002-CreateLocationTables.sql
|
└── schema.sql # full table/index/constraint definitions
|
||||||
├── 003-CreateBreweryTables.sql
|
|
||||||
└── ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Currently the whole schema is a single versioned script. As the schema evolves,
|
||||||
|
new numbered scripts get added alongside it rather than editing `schema.sql` in
|
||||||
|
place, so DbUp's already-applied tracking stays valid.
|
||||||
|
|
||||||
### Data seeding
|
### Data seeding
|
||||||
|
|
||||||
**Purpose**: Populate development/test databases
|
**Purpose**: Populate development/test databases with realistic data
|
||||||
|
|
||||||
**Implementation**: `Database.Seed` project
|
**Implementation**: `Database.Seed` project, using data produced by the C++
|
||||||
|
pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
|
||||||
|
|
||||||
**Seed Data**:
|
**Seed Data**:
|
||||||
|
|
||||||
- Countries, states/provinces, cities
|
- Cities (via `Features.Locations`' `ILocationRepository`)
|
||||||
- Test user accounts
|
- User accounts (via `Features.Auth`'s repository)
|
||||||
- Sample breweries (future)
|
- Brewery posts with locations (via `Features.Breweries`' repository)
|
||||||
|
|
||||||
|
Tables that exist in the schema but have no corresponding `Domain.Entities` type
|
||||||
|
or feature slice yet (`Photo`, `UserAvatar`, `UserFollow`, `Country`,
|
||||||
|
`StateProvince`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
|
||||||
|
`BreweryPostPhoto`) are not seeded.
|
||||||
|
|
||||||
## Deployment architecture
|
## Deployment architecture
|
||||||
|
|
||||||
@@ -470,6 +507,7 @@ scripts/
|
|||||||
- `database.migrations` - Schema migration runner
|
- `database.migrations` - Schema migration runner
|
||||||
- `database.seed` - Data seeder
|
- `database.seed` - Data seeder
|
||||||
- `api.core` - ASP.NET Core Web API
|
- `api.core` - ASP.NET Core Web API
|
||||||
|
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
|
||||||
|
|
||||||
**Environments**:
|
**Environments**:
|
||||||
|
|
||||||
|
|||||||
78
docs/website/database.md
Normal file
78
docs/website/database.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Database
|
||||||
|
|
||||||
|
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
|
||||||
|
convention. For the vertical-slice code that talks to this schema, see
|
||||||
|
[Architecture](../architecture.md).
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
The full schema lives in one script:
|
||||||
|
`web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql`, run by
|
||||||
|
`Database.Migrations` (DbUp) on startup. Each table is queried directly by the
|
||||||
|
owning feature slice's repository (see
|
||||||
|
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
|
||||||
|
|
||||||
|
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
|
||||||
|
most carry a `Timer ROWVERSION` column used for optimistic concurrency on
|
||||||
|
updates (`UPDATE ... WHERE ... AND Timer = @Timer`).
|
||||||
|
|
||||||
|
### Tables backed by the API today
|
||||||
|
|
||||||
|
| Table | Backed by | Notes |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique |
|
||||||
|
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||||
|
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
|
||||||
|
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||||
|
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read manually via `DbDataReader.GetFieldValue<byte[]>` because Dapper can't deserialize SQL Server UDTs |
|
||||||
|
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; looked up via `ILocationRepository`, currently consumed only by `Database.Seed` |
|
||||||
|
|
||||||
|
### Tables defined in the schema but not yet wired to a feature slice
|
||||||
|
|
||||||
|
These exist so the schema can support planned functionality, but have no
|
||||||
|
`Domain.Entities` type, repository, or seed data yet:
|
||||||
|
|
||||||
|
| Table | Purpose (from schema) |
|
||||||
|
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `Photo` | A photo uploaded by a user account |
|
||||||
|
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
|
||||||
|
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
|
||||||
|
| `StateProvince` | Referenced by `City.StateProvinceID` |
|
||||||
|
| `Country` | Referenced by `StateProvince.CountryID` |
|
||||||
|
| `BeerStyle` | A beer style/category |
|
||||||
|
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`) |
|
||||||
|
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
|
||||||
|
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
|
||||||
|
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
**Tool**: DbUp, run by the `Database.Migrations` project/container.
|
||||||
|
|
||||||
|
Scripts live under `scripts/`, applied in order and tracked so a script never
|
||||||
|
re-runs once it has succeeded. Today that's a single `01-schema/schema.sql`.
|
||||||
|
When the schema needs to change, add a new numbered script rather than editing
|
||||||
|
`schema.sql` in place — DbUp's tracking is per-script, so editing an
|
||||||
|
already-applied script means it won't re-run against existing databases.
|
||||||
|
|
||||||
|
## Data seeding
|
||||||
|
|
||||||
|
`Database.Seed` populates a target database from data produced by the C++
|
||||||
|
pipeline (see [Pipeline README](../pipeline/README.md)): cities, user accounts,
|
||||||
|
and brewery posts with locations. It only seeds tables that have a feature slice
|
||||||
|
to write through (see the table above).
|
||||||
|
|
||||||
|
## Database error handling
|
||||||
|
|
||||||
|
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
|
||||||
|
rows against the `Timer` column.
|
||||||
|
- On failure, the repository throws a `Domain.Exceptions` type directly
|
||||||
|
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
|
||||||
|
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
|
||||||
|
409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or
|
||||||
|
constraint-violation failure) maps to 503.
|
||||||
|
|
||||||
|
- One repository-level exception: `Features.Auth`'s `AuthRepository` does
|
||||||
|
catch `SqlException` directly, to detect a duplicate-key race (SQL Server
|
||||||
|
error 2601 or 2627) when two requests try to verify the same account
|
||||||
|
concurrently — see `Auth Repository.IsDuplicateVerificationViolation`.
|
||||||
Reference in New Issue
Block a user