Implements the Stable and Experimental JPA persistence platform designs against real PostgreSQL, adapted to this repository's fail-closed 19-leaf registry. The design models the platform as 25 Gradle projects. `src/settings.gradle` throws unless the registry holds exactly 19 leaves, so the plan's modules become packages inside `:adapter:outbound:persistence-jpa` (starter in `:app-bootstrap`, testkit in its own source set). The full mapping, the renames this repository's naming gate required, and every deliberate substitution are recorded in `docs/jpa/repository-adaptation.md`. Seven Docker-backed lanes replace the plan's seven JVM test suites. Each fails closed: a lane that discovers nothing, or a container that cannot start, is an error rather than a skip. Three defects the contracts found against a real server: - `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and transport breaks as completion-unknown. A backend terminated mid-commit reports 57P01, and the commit record may already be in the WAL — so a possibly-committed transaction could be re-run. 57P01/57P02/57P03 now classify as completion-unknown. - `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target version, which is empty for a tenant already current, reporting migrated tenants as unmigrated during a partial rollout. It now reads the applied version back from the tenant's schema history. - `JpaStreamExecutor` checked only the declared return type for reactive publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout to `SET`, which is parsed before parameter binding. `JpaModuleBoundaryTest` enforces the plan's module map as package rules; `verifyCleanArchitectureDependencies` governs edges between leaves and cannot see these. Its first assertion is that the import is non-empty, because every rule under it is a `noClasses()` rule and would pass vacuously on an empty import. Verified: 128 container tests across all seven lanes, 1183 unit tests, `:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`, `verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2.9 KiB
Migration Guide
Design §31-§32. Flyway owns the schema; Hibernate only validates.
Who may change the schema
| Environment | Mode |
|---|---|
| local, test, dev | migrate at startup with the migration credential |
| staging, prod | deployment-owned migration; the application validates only |
Migrating from inside the application in production means every instance of a rolling deploy races to apply the same script, and the loser's failure is indistinguishable from a real one.
ddl-auto is validate or none. Never update: it never drops or narrows anything, so it
produces a schema that is neither the old one nor the one the migrations describe — silently, on
whichever instance started first.
Validation fails closed and never repairs
FlywayValidationGate throws SchemaMismatchException on a checksum mismatch, a missing migration,
or a schema Hibernate disagrees with. It never calls repair.
Repair rewrites the schema history table to match whatever scripts are on disk. That resolves the symptom by deleting the evidence: a checksum mismatch means the deployed script differs from the applied one, and the interesting question is which change is missing from this database. Repair makes that question unaskable. It exists only as an explicit admin operation with an operator, a reason, and an approval (design §8.4).
Only Flyway's structured error codes reach the exception. Its messages embed the script path and part of the failing statement.
Concurrent index builds
CREATE INDEX CONCURRENTLY cannot run inside a transaction block, and Flyway wraps migrations in
one by default. The migration therefore needs a companion configuration:
# V42__order_index.sql.conf
executeInTransaction=false
ConcurrentIndexMigrationInspector fails validation without it, and additionally requires the
migration to contain nothing else. A failed concurrent build leaves an invalid index behind;
recovering is a single DROP INDEX when the migration did nothing else, and a manual reconstruction
of partial state when it did.
An invalid index is not merely useless — the planner ignores it while every write still maintains
it. FailedConcurrentIndexRecovery reports them with the statement to run, and deliberately does
not drop them: an invalid index can also mean a build is still running, and the two are
indistinguishable from the catalog alone.
Upgrade scenarios
Three, each catching something the others do not:
| Scenario | Catches |
|---|---|
empty |
an early migration edited to match a later one, no longer applying to a fresh database |
previous-release |
the actual deployment path; the only one exercising this release's migrations |
oldest-supported |
a migration that silently assumes state only recent databases have |
Each asserts a data invariant, not just the schema version. A migration that renames a column and loses its contents leaves the version correct and the data gone.