|
| 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). |
0 commit comments