Skip to content

Commit 83e0e5e

Browse files
authored
Merge pull request #4039 from BrighterCommand/database_migration
Database Utilities for our boxes
2 parents 4330588 + ce8bb9e commit 83e0e5e

407 files changed

Lines changed: 47422 additions & 477 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Box Provisioning and Migration
2+
3+
## Overview
4+
5+
Brighter includes a box provisioning system that creates and migrates Outbox and Inbox database tables at application startup. The system is modular with a core abstractions package and per-backend implementations. Specs 0023 (initial framework), 0027 (versioned migration chain), and 0028 (role interfaces + template-method bases) shipped together on the `database_migration` branch / PR #4039.
6+
7+
> Developer-facing companion guides live in `docs/guides/`:
8+
> - [box-provisioning-adding-columns.md](../docs/guides/box-provisioning-adding-columns.md) — workflow for adding a new Outbox/Inbox column across all backends.
9+
> - [box-provisioning-new-backend.md](../docs/guides/box-provisioning-new-backend.md) — workflow for implementing a brand-new BoxProvisioning backend that supports migrations.
10+
11+
## ⚠ Mandatory Rule: Adding a Column Requires a New Migration
12+
13+
> Every column added to a `*OutboxBuilder` or `*InboxBuilder` MUST ship with a new `V(N+1)` `BoxMigration` entry in the corresponding `*MigrationCatalog` class for the same backend.
14+
15+
A column added to the builder DDL without a matching migration entry is a schema drift. Existing deployments that already passed the previous fresh-install path will never run the new ALTER, so their tables fall behind silently and runtime SQL fails with "invalid column name" the next time the new column is read or written.
16+
17+
The new `BoxMigration` entry MUST populate:
18+
19+
- **`Version`** — strictly `previous_version + 1` (no gaps); same V-number across all backends for outbox columns where the column lands on every backend.
20+
- **`LogicalColumns`** — the cumulative column set after this migration applies (V_N's columns ∪ the new column). Used by drift detection and by `IAmAVersionDetectingMigrationHelper.DetectCurrentVersionAsync` for legacy-table version inference.
21+
- **`SourceReference`** — the commit SHA (and PR number where available) that introduced the column. Required from V2 onwards; V1 stays `null`.
22+
- **`IdempotencyCheckSql`** — SQLite only (its grammar lacks `ALTER TABLE ADD COLUMN IF NOT EXISTS`, so the runner needs an explicit existence probe). MSSQL / PostgreSQL / MySQL bake the existence check into the `UpScript` itself and leave `IdempotencyCheckSql` `null`.
23+
- **`UpScript`** — provider-appropriate idempotent ALTER:
24+
- **MSSQL**: `IF COL_LENGTH(N'[{schema}].[{table}]', N'{col}') IS NULL ALTER TABLE [{schema}].[{table}] ADD [{col}] {type} NULL;`
25+
- **PostgreSQL**: `ALTER TABLE {schema}.{table} ADD COLUMN IF NOT EXISTS {col} {type} NULL;`
26+
- **MySQL**: `information_schema.columns` existence check + prepared statement (`ALTER TABLE` cannot be parameterised).
27+
- **SQLite**: plain `ALTER TABLE [{table}] ADD COLUMN [{col}] {type} NULL;`. The runner skips the ALTER if `IdempotencyCheckSql` returns `> 0`.
28+
29+
Drift detection runs in CI: each backend's `When_*_builder_is_compared_to_*_migration_columns_*` test asserts `latest_migration.LogicalColumns ∪ housekeeping == DdlColumnExtractor.GetExpectedColumns(builder.GetDDL(...))`. The build fails if a column lands on the builder without a matching migration entry. See `tests/Paramore.Brighter.BoxProvisioning.Tests/Drift/` (parser) and `tests/Paramore.Brighter.{Backend}.Tests/BoxProvisioning/Drift/` (per-backend housekeeping + assertions).
30+
31+
For the per-backend migration history (V1..V7 outbox uniform across the four relational backends, V1..V2 inbox on MSSQL/MySQL/SQLite, V1-only inbox on Postgres) and rationale, see:
32+
- [ADR 0057](../docs/adr/0057-box-schema-versioning-and-migrations.md) — versioning model, three-path runner, advisory locks, Spanner exemption.
33+
- [ADR 0058](../docs/adr/0058-box-provisioning-rdd-role-interfaces.md) — role-based interfaces (RDD) and template-method abstract bases.
34+
- [ADR 0059](../docs/adr/0059-box-provisioning-abstract-base-naming-symmetry.md)`SqlBoxMigrationRunner` / `SqlBoxProvisioner` naming symmetry (no `*Base` suffix).
35+
- [Spec 0027 README](../specs/0027-box-schema-versioning-and-migrations/README.md) — archaeology of the V1..V7 chain.
36+
- [Spec 0028 README](../specs/0028-box-provisioning-rdd-role-interfaces/README.md) — RDD refresh + abstract-base pull-up.
37+
38+
## Package Structure
39+
40+
- **Core**: `src/Paramore.Brighter.BoxProvisioning/` — interfaces, abstract bases, hosted service, DI extensions.
41+
- **Backends**: `src/Paramore.Brighter.BoxProvisioning.{MsSql,PostgreSql,MySql,Sqlite,Spanner}/`.
42+
- **Existing builders**: `src/Paramore.Brighter.Outbox.{MsSql,PostgreSql,MySql,Sqlite,Spanner}/` and `src/Paramore.Brighter.Inbox.{...}/` (DDL generation; consumed by V1 migrations and by green-field installs).
43+
44+
## Key Abstractions (`src/Paramore.Brighter.BoxProvisioning/`)
45+
46+
| Type | Role |
47+
|------|------|
48+
| `IAmABoxMigration` | One versioned migration step (`Version`, `Description`, `UpScript`, `LogicalColumns`, `SourceReference`, `IdempotencyCheckSql`). |
49+
| `BoxMigration` (record) | Default implementation of `IAmABoxMigration`. |
50+
| `IAmABoxMigrationCatalog` | Per-backend, per-box-type ordered chain of migrations. Method: `IReadOnlyList<IAmABoxMigration> All(IAmARelationalDatabaseConfiguration)`. |
51+
| `IAmABoxMigrationDetectionHelper<TConnection, TTransaction>` | Probes: `DoesTableExistAsync`, `DoesHistoryExistAsync`, `GetMaxVersionAsync`, `GetTableColumnsAsync`, `DiscriminatorFor(BoxType)`. |
52+
| `IAmAVersionDetectingMigrationHelper<TConnection, TTransaction>` | Extends detection helper with `DetectCurrentVersionAsync` for legacy-table bootstrap. Relational only; Spanner exempt. |
53+
| `IAmABoxPayloadModeValidator<TConnection>` | Validates configured payload mode (binary/text) against live column type. |
54+
| `IAmAProvisioningUnitOfWork` | Transaction + advisory-lock lifecycle owned by the per-backend UoW. Spanner exempt. |
55+
| `IAmABoxProvisioner` | Per (backend × box-type) entry point; `ProvisionAsync(CancellationToken)`. |
56+
| `IAmABoxMigrationRunner` | Applies pending migrations under lock and writes history rows. |
57+
| `SqlBoxProvisioner<TConnection, TTransaction>` | Abstract base for the 8 relational provisioners (4 backends × Outbox/Inbox). Owns the orchestration body; derived classes supply only `CreateConnection` + `PayloadColumnName`. Spanner pair is free-standing. |
58+
| `SqlBoxMigrationRunner<TConnection, TTransaction>` | Abstract base for the 4 relational migration runners (template-method pattern; sealed `MigrateAsync` orchestration with abstract hooks). Spanner runner is free-standing. |
59+
| `BoxTableState` (record) | Snapshot of `(TableExists, HistoryExists, CurrentVersion)`. |
60+
| `BoxType` | `Outbox` or `Inbox`. |
61+
| `BoxProvisioningHostedService` | `IHostedService` — runs all registered provisioners at startup (Outbox before Inbox). |
62+
| `BoxProvisioningOptions` | Configuration builder threaded through `UseBoxProvisioning(o => ...)`. |
63+
| `BrighterBuilderBoxProvisioningExtensions` | `UseBoxProvisioning(this IBrighterBuilder, Action<BoxProvisioningOptions>)`. Single-call contract enforced by `BoxProvisioningMarker`. |
64+
| `Identifiers.AssertSafe(identifier, parameterName)` | Defence-in-depth chokepoint validating SQL identifiers against `^[A-Za-z][A-Za-z0-9_]*$`. Rejects leading digits and leading underscores (Spanner reserves `_`-prefixed names). |
65+
66+
## Per-Backend Layout
67+
68+
For each backend, the package `Paramore.Brighter.BoxProvisioning.{Backend}` contains the canonical family of files:
69+
70+
| File | Role / Base |
71+
|------|-------------|
72+
| `{Backend}OutboxMigrationCatalog.cs`, `{Backend}InboxMigrationCatalog.cs` | `IAmABoxMigrationCatalog` |
73+
| `{Backend}BoxDetectionHelper.cs` | `IAmAVersionDetectingMigrationHelper<TConn, TTx>` (Spanner: base `IAmABoxMigrationDetectionHelper` only) |
74+
| `{Backend}OutboxProvisioner.cs`, `{Backend}InboxProvisioner.cs` | `SqlBoxProvisioner<TConn, TTx>` (Spanner: implements `IAmABoxProvisioner` directly) |
75+
| `{Backend}BoxMigrationRunner.cs` | `SqlBoxMigrationRunner<TConn, TTx>` (Spanner: implements `IAmABoxMigrationRunner` directly) |
76+
| `{Backend}PayloadModeValidator.cs` | `IAmABoxPayloadModeValidator<TConn>` |
77+
| `{Backend}ProvisioningUnitOfWork.cs` | `IAmAProvisioningUnitOfWork` (Spanner: no UoW — optimistic concurrency) |
78+
| `{Backend}BoxProvisioningExtensions.cs` | DI extensions — `Add{Backend}Outbox`, `Add{Backend}Inbox` |
79+
80+
## Migration History
81+
82+
Applied migrations are tracked in `__BrighterMigrationHistory` with a composite primary key of (SchemaName, BoxTableName, MigrationVersion). Pre-migration tables are bootstrapped with synthetic history rows based on column introspection via `DetectCurrentVersionAsync`.
83+
84+
*Spanner exception:* the Spanner backend uses `BrighterMigrationHistory` (no leading underscores) because Spanner GoogleSQL rejects identifiers beginning with `_`. The framework-wide `Identifiers.AssertSafe` chokepoint applies the same strictest-backend rule to user-supplied table and schema names.
85+
86+
**Current versions**: V7 outbox uniform across the four relational backends (MSSQL/PG/MySQL/SQLite); V2 inbox on MSSQL/MySQL/SQLite; V1-only inbox on Postgres (shipped with `ContextKey` from day one — see ADR 0057 §1). Spanner is fresh-install-only (no V_k chain — ADR 0057 §6).
87+
88+
## Adding New Columns to the Outbox or Inbox
89+
90+
When Brighter needs a new column on the Outbox (or Inbox), the following files must be updated across **all 4 relational backends** (MSSQL, PostgreSQL, MySQL, SQLite) and the **Spanner builder** (no Spanner migration is added — see ADR 0057 §6).
91+
92+
### 1. Migration catalogs
93+
94+
Append a new `BoxMigration` with the next version number to each backend's catalog. See the **Mandatory Rule** section above for required fields. Also extend the `s_v(N+1)AddedColumns` array and the `Cumulative(int)` helper that builds `LogicalColumns`.
95+
96+
| Box Type | Files |
97+
|----------|----------------------------------------------------------------|
98+
| Outbox | `BoxProvisioning.{Backend}/{Backend}OutboxMigrationCatalog.cs` |
99+
| Inbox | `BoxProvisioning.{Backend}/{Backend}InboxMigrationCatalog.cs` |
100+
101+
**Rules:**
102+
- Version numbers must match across all four relational backends for outbox columns that land everywhere (Postgres inbox is V1-only by design — ADR 0057 §1).
103+
- New columns must be nullable or have a `DEFAULT` value (the runner cannot apply NOT-NULL adds against existing rows).
104+
- Use the provider-appropriate idempotent ALTER syntax — the runner may re-apply `UpScript` when a deployment has been bootstrapped from a legacy table and is mid-chain on a re-run.
105+
- Update both text and binary payload variants of the builder DDL if applicable.
106+
- Keep V1's `UpScript` pointed at the live builder DDL — it is the fresh-install fast path and stays in sync with the builder. Only V1's `LogicalColumns` changes on V1.
107+
108+
### 2. Version detection — **no change required**
109+
110+
`IAmAVersionDetectingMigrationHelper.DetectCurrentVersionAsync` (implemented once per relational backend in `{Backend}BoxDetectionHelper.cs`) walks the migration list `V_latest..V1` and returns the first version whose `LogicalColumns` is a subset of the table's actual columns. Detection is data-driven from `LogicalColumns`; do not edit detection helpers when adding a column.
111+
112+
### 3. Initial DDL builders
113+
114+
Update the CREATE TABLE DDL in the existing builder classes so new installations get the complete schema:
115+
116+
| Box Type | Files |
117+
|----------|-------------------------------------------------|
118+
| Outbox | `Outbox.{Backend}/{Backend}OutboxBuilder.cs` |
119+
| Inbox | `Inbox.{Backend}/{Backend}InboxBuilder.cs` |
120+
121+
**The Spanner builder MUST also be updated** — it is the only path by which Spanner installations receive the new column.
122+
123+
### 4. Read/write code (if the column is exercised at runtime)
124+
125+
Update INSERT and SELECT statements plus result mapping in the Outbox / Inbox implementation classes under `src/Paramore.Brighter.Outbox.{Backend}/` and `src/Paramore.Brighter.Inbox.{Backend}/`. These are **separate** from the `BoxProvisioning.{Backend}` projects.
126+
127+
### 5. Tests
128+
129+
Write tests for: migration application, idempotency, bootstrap detection, data round-trip, and **drift detection** — the per-backend drift test under `tests/Paramore.Brighter.{Backend}.Tests/BoxProvisioning/Drift/` will go RED the moment the builder changes in step 3 without a matching catalog entry from step 1, and flip GREEN when both are in place.
130+
131+
## Concurrency Control
132+
133+
Each backend uses database-specific locking during migrations:
134+
135+
| Backend | Mechanism | UoW class |
136+
|------------|----------------------------------------|----------------------------------------|
137+
| MSSQL | `sp_getapplock` (transaction-scoped) | `MsSqlProvisioningUnitOfWork` |
138+
| PostgreSQL | `pg_try_advisory_lock` (session-scoped)| `PostgreSqlProvisioningUnitOfWork` |
139+
| MySQL | `GET_LOCK` (session-scoped) | `MySqlProvisioningUnitOfWork` |
140+
| SQLite | `BEGIN IMMEDIATE` writer-slot | `SqliteProvisioningUnitOfWork` |
141+
| Spanner | Optimistic (single-stmt transactions) | — (no UoW; per ADR 0057 §6) |
142+
143+
## DI Registration
144+
145+
```csharp
146+
services
147+
.AddBrighter()
148+
.UseBoxProvisioning(opts =>
149+
{
150+
opts.AddMsSqlOutbox(configuration);
151+
opts.AddMsSqlInbox(configuration);
152+
// Optional: override the default 30s migration lock timeout
153+
opts.MigrationLockTimeout = TimeSpan.FromMinutes(2);
154+
});
155+
```
156+
157+
`UseBoxProvisioning` enforces a single-call contract — a second invocation throws `ConfigurationException`. Configure all outboxes and inboxes inside one delegate. The hosted service (`BoxProvisioningHostedService`) is registered via `TryAddEnumerable` and runs at app start, in Outbox-then-Inbox order, blocking until all provisioners complete.
158+
159+
## For Maintainers — Long-form playbooks
160+
161+
- Adding a column: `docs/guides/box-provisioning-adding-columns.md` (worked example, file-by-file checklist).
162+
- Adding a new backend that supports migrations: `docs/guides/box-provisioning-new-backend.md` (role-interface checklist, abstract-base wiring, drift-test setup, Spanner-exemption decisions).
163+
- Historical: `specs/0023-box_database_migration/adding-outbox-columns.md` (legacy long-form; superseded by the `docs/guides` guides — kept for archaeology).

.agent_instructions/code_style.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
- Use a single blank line to separate using directives.
1717
- DO NOT use Microsoft's Framework Design Guidelines. They are not idiomatic and outdated.
1818
- Prefer expression-bodied members for simple properties and methods.
19-
- Prefer primary constructors where possible, especially for simple classes and records.
19+
- Prefer primary constructors where possible, especially for simple classes and records. Use primary constructors as the default for new classes with constructor-injected dependencies — only fall back to traditional constructors when you need complex initialization logic or multiple constructors.
2020
- Use readonly for fields that do not change after construction.
2121
- Enable nullable on projects:
2222
- `<Nullable>enable</Nullable>` should be set in the project file, and that new code should use nullable reference types.

.agent_instructions/documentation.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,29 @@ Scan the ADR directory for existing ADRs to determine the next [Sequence Number]
6262

6363
Use dash-case (aka kebab-case) for the [Title] of the ADR.
6464

65+
## Writing tone for design documents
66+
67+
This guidance applies to ADRs, requirements specs, design specs, and any other long-lived document under `docs/` or `specs/`.
68+
69+
**Write for a future reader, not for the current conversation.** The audience is a contributor reading the document six months or two years from now to understand a design decision. They have no visibility into the chat that produced it.
70+
71+
**Refer to requirements and design artifacts, not to the participants in the authoring conversation.** Concretely:
72+
73+
- ❌ "at the user's direction" → ✅ "per requirement C3" or just state the decision
74+
- ❌ "the user's feedback was singular ('an abstract base class')" → ✅ "requirement F5 specifies a single abstract base"
75+
- ❌ "the user explicitly accepted this cost" → ✅ "the cost is accepted per requirement C1 (spec 0028 lands as PR review feedback, not greenfield work)"
76+
- ❌ "if the user wants the interface anyway during review, the re-introduction is mechanical" → ✅ remove — review-loop asides do not survive past the review
77+
- ❌ "Direct rendering from feedback item 5's framing" → ✅ "Aligns with requirement F4 (payload-mode validator role)"
78+
- ❌ "the spec 0027 PROMPT suggested otherwise" → ✅ replace with the actual technical reason; PROMPT.md is ephemeral working state
79+
80+
**Do not quote conversational asides.** Phrases like *"Arguably it would have been better caught earlier"* or *"we could possibly use ..."* belong in chat transcripts and PR review threads, not in design documents that outlive them.
81+
82+
**Do not reference ephemeral working state.** PROMPT.md, current spec phase ("we are in the requirements phase"), conversation transcripts, and unresolved review back-and-forth are all transient. Either fold the substance into the document, or omit it.
83+
84+
**Trace decisions to durable artifacts.** Acceptable references include: requirement IDs (F1, NF2, C3), other ADRs, code locations, principles in `.agent_instructions/`, and external specifications. References to GitHub PRs and issues are acceptable as historical anchors but should not carry the design rationale — the rationale must live in the document itself.
85+
86+
**The rule of thumb:** if removing the sentence would leave the future reader less informed about *the design*, keep it. If it would only leave them less informed about *who said what when*, remove it.
87+
6588
## Licensing
6689

6790
- We add a license comment to every src file

.agent_instructions/testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Name test methods in the format: When_[condition]_should_[expected_behavior].
66
- Name test classes `[Behavior]Tests` — the `When_` convention is for method names and file names only, never class names. For example `PipelineValidatorErrorAggregationTests`, `CommandProcessorPostBoxBulkClearAsyncTests`.
77
- Prefer a test case per file.
8+
- If you have multiple test cases per file (e.g. shared complex set up), the class name should describe the behavior being tested across all tests, for example `CommandProcessorPostBoxBulkClearAsyncTests`.
89
- Name test files for the test method in the file i.e. When_[condition]_should_[expected_behavior].cs
910
- If you decide to use multiple test cases per file, for example shared complex set up, name the file after the happy path test method and the class after the shared behavior.
1011
- Ensure all new features and bug fixes include appropriate test coverage.

0 commit comments

Comments
 (0)