feat(jpa): implement the JPA relational persistence platform
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b5aee50e3
commit
0e61f86eb5
@@ -18,6 +18,54 @@ This module is the RDBMS/JPA implementation base. It is not a datastore-neutral
|
||||
core for MongoDB, Redis, DynamoDB, or other NoSQL stores. Future NoSQL persistence
|
||||
adapters implement application/domain ports directly and must not depend on this module.
|
||||
|
||||
## JPA relational persistence platform
|
||||
|
||||
The platform in `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` is
|
||||
implemented here. The design models it as 18 Stable library modules; this repository's fail-closed
|
||||
19-leaf registry outranks that layout, so those modules are **packages** in this leaf and
|
||||
`docs/jpa/repository-adaptation.md` records the mapping. Read it before moving a type between
|
||||
packages.
|
||||
|
||||
| Platform package | Owns |
|
||||
|---|---|
|
||||
| `api` (+ `.capability`, `.error`, `.query`, `.transaction`) | framework-free contracts: operation and query names, the stable error hierarchy, transaction and retry profiles, keyset cursors |
|
||||
| `transaction` | commit evidence, the Spring executor, full-transaction retry, completion-unknown reconciliation |
|
||||
| `springdata` | repository fragments, safe sort, fetch plans, keyset execution, stream guard |
|
||||
| `hibernate` (+ `.batch`, `.bulk`, `.stateless`) | statement inspector, statistics, batch, bulk DML, stateless session |
|
||||
| `postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`, `.copy`) | SQLSTATE classification, locks and work claims, JSONB, arrays and ranges, upserts, COPY |
|
||||
| `migration` | Flyway policy, validate gate, concurrent-index guard |
|
||||
| `auditing`, `cache`, `envers`, `querydsl`, `security`, `observation` | opt-in capabilities and the runtime-role verifier |
|
||||
| `experimental` | multi-tenancy, RLS, read replica, forward-compatibility lanes — all flag-gated |
|
||||
| `testkit` (`src/testkit/java`) | ArchUnit rules, fixtures, query/plan assertions, PostgreSQL matrix, failure injection |
|
||||
|
||||
### Non-negotiables
|
||||
|
||||
- No `GenericRepository<T, ID>` and no platform base repository. Domains own their repositories.
|
||||
- `TransactionCompletionUnknownException` is never retried. `JpaFailureContext` refuses to
|
||||
represent a retryable completion-unknown failure, so a policy bug cannot produce one.
|
||||
- Retry re-runs the whole use case in a new transaction and a new Persistence Context.
|
||||
- OSIV is false in every runtime profile; Flyway owns schema change and Hibernate only validates.
|
||||
- Metric tags, exception messages, and logs carry no SQL parameters, entity ids, tenant ids, or PII.
|
||||
- Contracts run against real PostgreSQL. H2 never satisfies one.
|
||||
|
||||
### Platform lanes
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-jpa:test # hermetic unit lane
|
||||
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest # real PostgreSQL
|
||||
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest # Flyway upgrade scenarios
|
||||
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformFailureTest # deadlock, commit ambiguity
|
||||
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest # EXPLAIN structure
|
||||
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest # runtime role privileges
|
||||
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest # pool pressure
|
||||
./gradlew jpaReleaseGate # every gate, from the root
|
||||
```
|
||||
|
||||
Every Docker-backed lane fails closed. A selected lane that discovers nothing, or a container that
|
||||
cannot start, is an error rather than a skip — a skipped contract reports success for a database
|
||||
nobody tested.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- JPA entities.
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
// implementations, and the vendor-neutral SPI interfaces (OutboxClaimRepository /
|
||||
// SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor
|
||||
// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral).
|
||||
// The JPA relational persistence platform (docs/superpowers/specs/2026-08-11-jpa-persistence-
|
||||
// platform-design.md) models itself as 18 Stable library modules. This repository's fail-closed
|
||||
// 19-leaf registry outranks that layout, so those modules are packages here and
|
||||
// JpaModuleBoundaryTest enforces the design's module dependency table. The full mapping is in
|
||||
// docs/jpa/repository-adaptation.md.
|
||||
//
|
||||
// The testkit is its own source set rather than part of `test` because more than one lane consumes
|
||||
// it and because a source set whose dependencies are declared only on the test configurations gives
|
||||
// the design's "no production module depends on the testkit" guarantee without a new Gradle project.
|
||||
sourceSets {
|
||||
postgresqlIntegrationTest {
|
||||
java.setSrcDirs(['src/postgresqlIntegrationTest/java'])
|
||||
@@ -10,6 +19,16 @@ sourceSets {
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
testkit {
|
||||
java.srcDir 'src/testkit/java'
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
jpaPlatformPerformanceTest {
|
||||
java.srcDir 'src/jpaPlatformPerformanceTest/java'
|
||||
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
@@ -17,6 +36,20 @@ configurations {
|
||||
postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly
|
||||
postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor
|
||||
testkitImplementation.extendsFrom testImplementation
|
||||
testkitRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
jpaPlatformPerformanceTestImplementation.extendsFrom testImplementation
|
||||
jpaPlatformPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
}
|
||||
|
||||
// Every test lane compiles and runs against the testkit.
|
||||
sourceSets.test {
|
||||
compileClasspath += sourceSets.testkit.output
|
||||
runtimeClasspath += sourceSets.testkit.output
|
||||
}
|
||||
sourceSets.postgresqlIntegrationTest {
|
||||
compileClasspath += sourceSets.testkit.output
|
||||
runtimeClasspath += sourceSets.testkit.output
|
||||
}
|
||||
|
||||
ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine'
|
||||
@@ -43,9 +76,41 @@ dependencies {
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// JPA platform observability (design §37). Micrometer's observation API already arrives with
|
||||
// Spring; the meter registry does not, and the platform's transaction/query/retry metrics need
|
||||
// it. Version managed by the Spring Boot BOM.
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
|
||||
// Querydsl and Envers are Advanced opt-ins (design §4.2): the platform implements their
|
||||
// contracts, but the Stable runtime classpath must not carry either. compileOnly keeps them off
|
||||
// every deployment while still compiling the support classes; a deployment that opts in adds the
|
||||
// artifact itself, and the guards refuse the capability when the classes are absent.
|
||||
compileOnly 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
|
||||
compileOnly 'org.hibernate.orm:hibernate-envers'
|
||||
|
||||
testImplementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
|
||||
testImplementation 'org.hibernate.orm:hibernate-envers'
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
|
||||
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-toxiproxy'
|
||||
postgresqlIntegrationTestRuntimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
testkitImplementation 'org.testcontainers:testcontainers'
|
||||
testkitImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||
testkitImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
testkitImplementation 'org.testcontainers:testcontainers-toxiproxy'
|
||||
testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
testkitRuntimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
// The performance lane starts its own servers: pool saturation is only observable against a
|
||||
// real database, because the thing being measured is what happens when every connection to it
|
||||
// is already held.
|
||||
jpaPlatformPerformanceTestImplementation 'org.testcontainers:testcontainers'
|
||||
jpaPlatformPerformanceTestImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||
jpaPlatformPerformanceTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
jpaPlatformPerformanceTestRuntimeOnly 'org.postgresql:postgresql'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
@@ -169,4 +234,80 @@ postgresqlSecurityBaselineIntegrationTest.configure {
|
||||
dependsOn project(':adapter:inbound:web').tasks.named('jpaPersistenceRedactionContractTest')
|
||||
}
|
||||
|
||||
// JPA platform lanes (design §40-§41). Each maps one of the plan's JVM test suites onto this
|
||||
// leaf's existing Docker-backed source set; the mapping is recorded in
|
||||
// docs/jpa/repository-adaptation.md §3.
|
||||
//
|
||||
// Every lane fails closed. `failOnNoDiscoveredTests` matters more here than usual: a selected lane
|
||||
// that discovers nothing reports success, and a contract suite that silently stopped running is
|
||||
// indistinguishable from one that passes.
|
||||
Closure<Void> registerJpaPlatformLane = { String taskName, String tag, String description ->
|
||||
tasks.register(taskName, Test) {
|
||||
group = 'verification'
|
||||
it.description = description
|
||||
testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs
|
||||
classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath
|
||||
useJUnitPlatform {
|
||||
includeTags tag
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs('-Duser.timezone=UTC')
|
||||
// The Stable matrix selection. An unknown or empty value is an error in
|
||||
// PostgreSqlVersion.parseSelection rather than an empty run.
|
||||
systemProperty 'jpa.matrix.versions',
|
||||
(project.findProperty('jpa.matrix.versions') ?: '16').toString()
|
||||
}
|
||||
}
|
||||
|
||||
def jpaPlatformContractTest = registerJpaPlatformLane(
|
||||
'jpaPlatformContractTest',
|
||||
'jpa-contract',
|
||||
'Runs the JPA platform contract suite against real PostgreSQL (design §40).')
|
||||
def jpaPlatformMigrationTest = registerJpaPlatformLane(
|
||||
'jpaPlatformMigrationTest',
|
||||
'jpa-migration',
|
||||
'Runs the Flyway upgrade snapshot scenarios (design §31).')
|
||||
def jpaPlatformFailureTest = registerJpaPlatformLane(
|
||||
'jpaPlatformFailureTest',
|
||||
'jpa-failure',
|
||||
'Reproduces deadlock, serialization, and commit-ambiguity failures (design §39).')
|
||||
def jpaPlatformQueryPlanTest = registerJpaPlatformLane(
|
||||
'jpaPlatformQueryPlanTest',
|
||||
'jpa-queryplan',
|
||||
'Asserts query plan structure and planner estimate error (design §33).')
|
||||
def jpaPlatformSecurityTest = registerJpaPlatformLane(
|
||||
'jpaPlatformSecurityTest',
|
||||
'jpa-security',
|
||||
'Verifies runtime role privileges and search_path safety (design §36).')
|
||||
|
||||
// Machine-dependent bounds live in their own source set and never gate an ordinary build: attaching
|
||||
// them to `check` would make a laptop's `check` fail for reasons that are not about the code.
|
||||
def jpaPlatformPerformanceTest = tasks.register('jpaPlatformPerformanceTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Certifies Hikari pool and REQUIRES_NEW connection pressure (design §38).'
|
||||
testClassesDirs = sourceSets.jpaPlatformPerformanceTest.output.classesDirs
|
||||
classpath = sourceSets.jpaPlatformPerformanceTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs('-Duser.timezone=UTC')
|
||||
systemProperty 'performance.assertions.enabled',
|
||||
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
|
||||
}
|
||||
|
||||
// The JPA release gate (design §41). Aggregates every lane whose absence would let one of the
|
||||
// documented gates in docs/jpa/support-matrix.md pass unverified.
|
||||
tasks.register('jpaPlatformReleaseGate') {
|
||||
group = 'verification'
|
||||
description = 'Runs every JPA platform lane required for a release (design §41).'
|
||||
dependsOn tasks.named('test')
|
||||
dependsOn jpaPlatformContractTest
|
||||
dependsOn jpaPlatformMigrationTest
|
||||
dependsOn jpaPlatformFailureTest
|
||||
dependsOn jpaPlatformQueryPlanTest
|
||||
dependsOn jpaPlatformSecurityTest
|
||||
dependsOn jpaPlatformPerformanceTest
|
||||
}
|
||||
|
||||
apply from: rootProject.file('gradle/jpa-evidence.gradle')
|
||||
|
||||
@@ -1,211 +1,226 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.github.docker-java:docker-java-api:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport-zerodep:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport:3.7.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.github.docker-java:docker-java-api:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport-zerodep:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,postgresqlIntegrationTestCompileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,spotbugs,testCompileClasspath,testkitCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.h2database:h2:2.4.240=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
com.h2database:h2:2.4.240=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.jayway.jsonpath:json-path:2.9.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.mysema.commons:mysema-commons-lang:0.2.4=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.sun.istack:istack-commons-runtime:4.1.2=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.zaxxer:HikariCP:7.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.querydsl:querydsl-core:5.1.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.querydsl:querydsl-jpa:5.1.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.sun.istack:istack-commons-runtime:4.1.2=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-api:1.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-engine:1.3.0=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5:1.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit:1.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.zaxxer:HikariCP:7.0.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-codec:commons-codec:1.19.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
commons-codec:commons-codec:1.19.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
commons-io:commons-io:2.20.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.inject:jakarta.inject-api:2.0.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
io.smallrye:jandex:3.3.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
jakarta.inject:jakarta.inject-api:2.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.java.dev.jna:jna:5.18.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
net.java.dev.jna:jna:5.18.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-compress:1.28.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs
|
||||
org.apache.commons:commons-compress:1.28.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||
org.aspectj:aspectjweaver:1.9.25=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.assertj:assertj-core:3.27.6=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.49.5=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.apiguardian:apiguardian-api:1.1.2=jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
|
||||
org.aspectj:aspectjweaver:1.9.25=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.assertj:assertj-core:3.27.6=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.49.5=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.eclipse.angus:angus-activation:2.0.3=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-core:11.14.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-database-postgresql:11.14.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.glassfish.jaxb:jaxb-core:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.glassfish.jaxb:jaxb-runtime:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.glassfish.jaxb:txw2:4.0.6=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.hamcrest:hamcrest:3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.hibernate.models:hibernate-models:1.0.1=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.eclipse.angus:angus-activation:2.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.flywaydb:flyway-core:11.14.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.flywaydb:flyway-database-postgresql:11.14.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.glassfish.jaxb:jaxb-core:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.glassfish.jaxb:jaxb-runtime:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.glassfish.jaxb:txw2:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.hamcrest:hamcrest:3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.hdrhistogram:HdrHistogram:2.2.2=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.hibernate.models:hibernate-models:1.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.hibernate.orm:hibernate-envers:7.1.8.Final=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.jetbrains:annotations:17.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,postgresqlIntegrationTestAnnotationProcessor,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.jetbrains:annotations:17.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,jpaPlatformPerformanceTestAnnotationProcessor,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestAnnotationProcessor,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath
|
||||
org.latencyutils:LatencyUtils:2.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.mockito:mockito-core:5.20.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,mockitoAgent,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor
|
||||
org.postgresql:postgresql:42.7.8=postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm:9.7.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
|
||||
org.postgresql:postgresql:42.7.8=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.rnorth.duct-tape:duct-tape:1.0.8=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
org.skyscreamer:jsonassert:1.5.3=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.rnorth.duct-tape:duct-tape:1.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.skyscreamer:jsonassert:1.5.3=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.integration:spring-integration-core:7.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.integration:spring-integration-jdbc:7.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aspects:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-jdbc:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-messaging:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-orm:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-tx:7.0.1=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-database-commons:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
org.testcontainers:testcontainers-jdbc:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
org.testcontainers:testcontainers-junit-jupiter:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
org.testcontainers:testcontainers-postgresql:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.integration:spring-integration-core:7.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.integration:spring-integration-jdbc:7.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-aspects:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-jdbc:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-messaging:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-orm:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-tx:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.testcontainers:testcontainers-database-commons:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.testcontainers:testcontainers-jdbc:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.testcontainers:testcontainers-junit-jupiter:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.testcontainers:testcontainers-postgresql:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.testcontainers:testcontainers-toxiproxy:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.testcontainers:testcontainers:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlunit:xmlunit-core:2.10.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
empty=
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.platform.pool;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.pool.PoolMeasurement;
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory;
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlVersion;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
|
||||
/**
|
||||
* A finite pool refuses rather than waiting forever (design §38).
|
||||
*
|
||||
* <p>The bound being asserted is behavioural, not machine-dependent: with every connection held, a
|
||||
* further acquisition must fail within the configured timeout. That is the difference between pool
|
||||
* exhaustion presenting as failed requests and presenting as requests that never return.
|
||||
*/
|
||||
class HikariPoolSaturationContractTest {
|
||||
|
||||
private static final int POOL_SIZE = 2;
|
||||
private static final Duration ACQUIRE_TIMEOUT = Duration.ofMillis(500);
|
||||
|
||||
private static PostgreSQLContainer container;
|
||||
|
||||
@BeforeAll
|
||||
static void startServer() {
|
||||
container = PostgreSqlContainerFactory.create(PostgreSqlVersion.PG_16);
|
||||
container.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopServer() {
|
||||
if (container != null) {
|
||||
container.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a saturated pool fails within its acquisition timeout")
|
||||
void saturatedPoolFailsWithinItsTimeout() throws SQLException {
|
||||
try (HikariDataSource pool = pool()) {
|
||||
List<Connection> held = new ArrayList<>();
|
||||
try {
|
||||
for (int index = 0; index < POOL_SIZE; index++) {
|
||||
held.add(pool.getConnection());
|
||||
}
|
||||
|
||||
Instant startedAt = Instant.now();
|
||||
assertThatThrownBy(pool::getConnection).isInstanceOf(SQLException.class);
|
||||
Duration waited = Duration.between(startedAt, Instant.now());
|
||||
|
||||
assertThat(waited)
|
||||
.as("an unbounded wait turns exhaustion into requests that never return")
|
||||
.isLessThan(ACQUIRE_TIMEOUT.plusSeconds(2));
|
||||
} finally {
|
||||
for (Connection connection : held) {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("releasing a connection lets the next caller through")
|
||||
void releasingAConnectionLetsTheNextCallerThrough() throws SQLException {
|
||||
try (HikariDataSource pool = pool()) {
|
||||
Connection first = pool.getConnection();
|
||||
Connection second = pool.getConnection();
|
||||
first.close();
|
||||
|
||||
try (Connection third = pool.getConnection()) {
|
||||
assertThat(third.isValid(1)).isTrue();
|
||||
}
|
||||
second.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the measurement reports what the pool is actually doing")
|
||||
void measurementReportsPoolState() throws SQLException {
|
||||
try (HikariDataSource pool = pool()) {
|
||||
try (Connection held = pool.getConnection()) {
|
||||
var bean = pool.getHikariPoolMXBean();
|
||||
var measurement =
|
||||
new PoolMeasurement(
|
||||
bean.getActiveConnections(),
|
||||
bean.getIdleConnections(),
|
||||
bean.getThreadsAwaitingConnection(),
|
||||
Duration.ZERO);
|
||||
|
||||
assertThat(measurement.active()).isEqualTo(1);
|
||||
assertThat(measurement.total()).isGreaterThanOrEqualTo(1);
|
||||
assertThat(held.isValid(1)).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HikariDataSource pool() {
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(container.getJdbcUrl());
|
||||
config.setUsername(container.getUsername());
|
||||
config.setPassword(container.getPassword());
|
||||
config.setMaximumPoolSize(POOL_SIZE);
|
||||
config.setConnectionTimeout(ACQUIRE_TIMEOUT.toMillis());
|
||||
return new HikariDataSource(config);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.platform.pool;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.pool.PoolMeasurement;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Pool pressure certification (design §38).
|
||||
*
|
||||
* <p>The lane reports rather than asserts unless {@code performance.assertions.enabled} is set,
|
||||
* because the numbers depend on the machine. A shared CI runner producing a red build for a
|
||||
* threshold it never had the resources to meet teaches people to ignore the lane.
|
||||
*
|
||||
* <p>What is asserted unconditionally is the arithmetic the pool has to satisfy. With {@code
|
||||
* REQUIRES_NEW}, a thread holds the outer transaction's connection while acquiring a second one, so
|
||||
* a pool sized for the thread count alone deadlocks with every connection held by a thread waiting
|
||||
* for another connection.
|
||||
*/
|
||||
class PoolPressureContractTest {
|
||||
|
||||
private static final boolean ASSERTIONS_ENABLED =
|
||||
Boolean.parseBoolean(System.getProperty("performance.assertions.enabled", "false"));
|
||||
|
||||
@Test
|
||||
@DisplayName("pending count and acquire latency are reported together")
|
||||
void reportsPendingAndAcquireLatencyTogether() {
|
||||
var measurement = new PoolMeasurement(4, 2, 3, Duration.ofMillis(80));
|
||||
|
||||
assertThat(measurement.pending()).isEqualTo(3);
|
||||
assertThat(measurement.acquireLatency()).isEqualTo(Duration.ofMillis(80));
|
||||
assertThat(measurement.total()).isEqualTo(6);
|
||||
assertThat(measurement.saturated()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a REQUIRES_NEW depth of one needs two connections per concurrent thread")
|
||||
void requiresNewNeedsTwoConnectionsPerThread() {
|
||||
int concurrentThreads = 8;
|
||||
int maxRequiresNewDepth = 1;
|
||||
|
||||
int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1;
|
||||
|
||||
assertThat(required).isEqualTo(17);
|
||||
if (!ASSERTIONS_ENABLED) {
|
||||
// Machine-dependent bounds are not asserted in this run; the arithmetic above is.
|
||||
assertThat(ASSERTIONS_ENABLED).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.platform.pool;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory;
|
||||
import dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlVersion;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
|
||||
/**
|
||||
* {@code REQUIRES_NEW} needs a second connection while pinning the first (design §38).
|
||||
*
|
||||
* <p>This is the arithmetic behind the pool-sizing rule, demonstrated rather than asserted from a
|
||||
* formula: one thread holding an outer connection and asking for an inner one needs two, and a pool
|
||||
* sized for the thread count alone deadlocks with every connection held by a thread waiting for
|
||||
* another connection.
|
||||
*/
|
||||
class RequiresNewPoolPressureContractTest {
|
||||
|
||||
private static PostgreSQLContainer container;
|
||||
|
||||
@BeforeAll
|
||||
static void startServer() {
|
||||
container = PostgreSqlContainerFactory.create(PostgreSqlVersion.PG_16);
|
||||
container.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopServer() {
|
||||
if (container != null) {
|
||||
container.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a pool of one deadlocks the moment an inner transaction is needed")
|
||||
void poolOfOneCannotServeAnInnerTransaction() throws SQLException {
|
||||
try (HikariDataSource pool = pool(1)) {
|
||||
try (Connection outer = pool.getConnection()) {
|
||||
outer.setAutoCommit(false);
|
||||
|
||||
assertThatThrownBy(pool::getConnection)
|
||||
.as("the outer transaction still holds its connection while the inner one is opened")
|
||||
.isInstanceOf(SQLException.class);
|
||||
|
||||
outer.rollback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a pool of two serves the same nesting")
|
||||
void poolOfTwoServesTheSameNesting() throws SQLException {
|
||||
try (HikariDataSource pool = pool(2)) {
|
||||
try (Connection outer = pool.getConnection()) {
|
||||
outer.setAutoCommit(false);
|
||||
|
||||
try (Connection inner = pool.getConnection()) {
|
||||
inner.setAutoCommit(false);
|
||||
assertThat(inner.isValid(1)).isTrue();
|
||||
assertThat(inner).isNotSameAs(outer);
|
||||
inner.commit();
|
||||
}
|
||||
|
||||
outer.rollback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the sizing rule matches the observed requirement")
|
||||
void sizingRuleMatchesTheObservedRequirement() {
|
||||
int concurrentThreads = 1;
|
||||
int maxRequiresNewDepth = 1;
|
||||
|
||||
int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1;
|
||||
|
||||
assertThat(required)
|
||||
.as("one thread at depth one needs two connections; the rule adds headroom")
|
||||
.isEqualTo(3);
|
||||
}
|
||||
|
||||
private static HikariDataSource pool(int size) {
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(container.getJdbcUrl());
|
||||
config.setUsername(container.getUsername());
|
||||
config.setPassword(container.getPassword());
|
||||
config.setMaximumPoolSize(size);
|
||||
config.setConnectionTimeout(500L);
|
||||
return new HikariDataSource(config);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Bounded, low-cardinality identity for one logical persistence operation (design §9.1).
|
||||
*
|
||||
* <p>The value is the key used by metrics, traces, and retry policy, so it must never carry a
|
||||
* dynamic identifier: no entity id, no tenant id, no SQL fragment, no request-scoped value. The
|
||||
* format is fixed by the design and validated in the canonical constructor, which is what keeps
|
||||
* metric cardinality bounded at the type level rather than by convention.
|
||||
*/
|
||||
public record PersistenceOperationName(String value) {
|
||||
|
||||
/** Design §9.1 — the exact accepted shape of an operation name. */
|
||||
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}");
|
||||
|
||||
public PersistenceOperationName {
|
||||
if (value == null || !FORMAT.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("invalid persistence operation name");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.capability;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* An immutable declaration that one {@link JpaCapability} is supported at one {@link SupportLevel}
|
||||
* under a fixed list of constraints.
|
||||
*
|
||||
* <p>Constraints are plain bounded strings on purpose. A capability record must stay serialisable
|
||||
* into a report and safe to publish through an actuator endpoint, so it never stores a provider
|
||||
* object — no {@code DataSource}, no {@code EntityManagerFactory}, no Hibernate {@code
|
||||
* SessionFactory}. Holding one would drag a live resource into a value type and let a report leak a
|
||||
* JDBC URL or credentials.
|
||||
*/
|
||||
public record CapabilitySupport(
|
||||
JpaCapability capability, SupportLevel level, List<String> constraints) {
|
||||
|
||||
public CapabilitySupport {
|
||||
Objects.requireNonNull(capability, "capability");
|
||||
Objects.requireNonNull(level, "level");
|
||||
constraints = List.copyOf(Objects.requireNonNull(constraints, "constraints"));
|
||||
for (String constraint : constraints) {
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
throw new IllegalArgumentException("capability constraint must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Declares a capability with no additional constraint. */
|
||||
public static CapabilitySupport of(JpaCapability capability, SupportLevel level) {
|
||||
return new CapabilitySupport(capability, level, List.of());
|
||||
}
|
||||
|
||||
/** Declares a capability that callers may rely on without an extra opt-in. */
|
||||
public static CapabilitySupport stable(JpaCapability capability, String... constraints) {
|
||||
return new CapabilitySupport(capability, SupportLevel.STABLE, List.of(constraints));
|
||||
}
|
||||
|
||||
/** Declares a capability that requires an explicit dependency, registration, or token. */
|
||||
public static CapabilitySupport advanced(JpaCapability capability, String... constraints) {
|
||||
return new CapabilitySupport(capability, SupportLevel.ADVANCED, List.of(constraints));
|
||||
}
|
||||
|
||||
/** Declares a capability that is only reachable behind an experimental feature flag. */
|
||||
public static CapabilitySupport experimental(JpaCapability capability, String... constraints) {
|
||||
return new CapabilitySupport(capability, SupportLevel.EXPERIMENTAL, List.of(constraints));
|
||||
}
|
||||
|
||||
/** Whether an ordinary application may use this capability without a further opt-in. */
|
||||
public boolean usableByDefault() {
|
||||
return level == SupportLevel.STABLE;
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.capability;
|
||||
|
||||
/**
|
||||
* The named capabilities the JPA persistence platform can report on (design §4, §8).
|
||||
*
|
||||
* <p>A capability is a contract the platform either supports at a declared {@link SupportLevel} or
|
||||
* does not. The id is a bounded metric/report key, so it follows the same low-cardinality rule as
|
||||
* {@link dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName}.
|
||||
*/
|
||||
public enum JpaCapability {
|
||||
|
||||
/** Full use-case retry in a new transaction and a new Persistence Context (design §19). */
|
||||
TRANSACTION_RETRY("transaction.retry"),
|
||||
|
||||
/** Commit-phase evidence tracking that yields completion-unknown instead of a guess (§17). */
|
||||
COMPLETION_EVIDENCE("transaction.completion-evidence"),
|
||||
|
||||
/** Signed, tie-broken keyset cursors instead of deep offset pagination (§27). */
|
||||
KEYSET_PAGINATION("query.keyset-pagination"),
|
||||
|
||||
/** Verified JDBC batch execution with bounded Persistence Context growth (§28). */
|
||||
BATCH("write.jdbc-batch"),
|
||||
|
||||
/** Registered PostgreSQL {@code ON CONFLICT} / {@code RETURNING} writes (§8.3). */
|
||||
POSTGRESQL_NATIVE_WRITE("postgresql.native-write"),
|
||||
|
||||
/** Flyway-owned schema with a fail-closed Hibernate validate gate (§31). */
|
||||
SCHEMA_GATE("migration.schema-gate"),
|
||||
|
||||
/** Selective Hibernate second-level cache with Query Cache off by default (§34). */
|
||||
L2_CACHE("cache.hibernate-l2"),
|
||||
|
||||
/** Opt-in Hibernate Envers entity history (§35). */
|
||||
ENVERS("history.envers"),
|
||||
|
||||
/** PostgreSQL {@code FOR UPDATE SKIP LOCKED} queue claims (§21.3). */
|
||||
POSTGRESQL_WORK_CLAIM("postgresql.work-claim"),
|
||||
|
||||
/** Versioned JSONB document mapping and registered path queries (§8.3). */
|
||||
POSTGRESQL_JSONB("postgresql.jsonb"),
|
||||
|
||||
/** Typed array and bounded/unbounded range mapping (§8.3). */
|
||||
POSTGRESQL_ARRAY_RANGE("postgresql.array-range"),
|
||||
|
||||
/** Admin-only bounded {@code COPY} bulk load (§30). */
|
||||
POSTGRESQL_COPY("postgresql.copy"),
|
||||
|
||||
/** Hibernate {@code StatelessSession} bulk runner (§30.1). */
|
||||
STATELESS_SESSION("hibernate.stateless-session"),
|
||||
|
||||
/** Bulk DML with mandatory flush → statement → clear ordering (§29). */
|
||||
BULK_DML("write.bulk-dml"),
|
||||
|
||||
/** Runtime database role and {@code search_path} verification (§36). */
|
||||
RUNTIME_ROLE_VERIFICATION("security.runtime-role"),
|
||||
|
||||
/** Bounded-tag metrics, tracing, and redacted SQL diagnostics (§37). */
|
||||
OBSERVABILITY("observability.jpa");
|
||||
|
||||
private final String id;
|
||||
|
||||
JpaCapability(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** The bounded report/metric key for this capability. */
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.capability;
|
||||
|
||||
/**
|
||||
* How far a platform capability is supported (design §4, §7.1).
|
||||
*
|
||||
* <p>The level is declared, not inferred: a capability whose evidence suite has not run is not
|
||||
* {@link #STABLE} merely because the code compiles.
|
||||
*/
|
||||
public enum SupportLevel {
|
||||
|
||||
/** Verified by the Stable contract suite on the whole PostgreSQL Stable matrix. */
|
||||
STABLE,
|
||||
|
||||
/** Available, but requires an explicit module dependency, registration, or capability token. */
|
||||
ADVANCED,
|
||||
|
||||
/** Behind a {@code backend.jpa.experimental.*} flag; never part of the Stable composition. */
|
||||
EXPERIMENTAL,
|
||||
|
||||
/** Explicitly out of scope (design §4.4); selecting it is a configuration error. */
|
||||
UNSUPPORTED
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A check constraint rejected the write ({@code 23514}).
|
||||
*
|
||||
* <p>The database enforced an invariant the application should have enforced first; re-running the
|
||||
* same write cannot succeed.
|
||||
*/
|
||||
public final class CheckConstraintViolationException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient ConstraintViolationDetails details;
|
||||
|
||||
public CheckConstraintViolationException(
|
||||
JpaFailureContext context, ConstraintViolationDetails details, Throwable cause) {
|
||||
super(FailureCategory.CHECK_CONSTRAINT, contextWithConstraint(context, details), cause);
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public CheckConstraintViolationException(
|
||||
JpaFailureContext context, ConstraintViolationDetails details) {
|
||||
this(context, details, null);
|
||||
}
|
||||
|
||||
/** The registered constraint code, and the bounded physical name when the server reported one. */
|
||||
public ConstraintViolationDetails details() {
|
||||
return details;
|
||||
}
|
||||
|
||||
private static JpaFailureContext contextWithConstraint(
|
||||
JpaFailureContext context, ConstraintViolationDetails details) {
|
||||
Objects.requireNonNull(details, "details");
|
||||
return details.databaseName().map(context::withConstraintName).orElse(context);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* A connection could not be obtained, or was lost outside the commit phase.
|
||||
*
|
||||
* <p>A connection failure is only escalated to {@link TransactionCompletionUnknownException} when
|
||||
* it happens while the transaction is committing; classifying every connection loss as
|
||||
* completion-unknown would make ordinary pool exhaustion look like possible data loss (design
|
||||
* §17.2).
|
||||
*/
|
||||
public final class ConnectionUnavailableException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ConnectionUnavailableException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.CONNECTION_UNAVAILABLE, context, cause);
|
||||
}
|
||||
|
||||
public ConnectionUnavailableException(JpaFailureContext context) {
|
||||
super(FailureCategory.CONNECTION_UNAVAILABLE, context);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The application-owned identity of a database constraint (design §22.4).
|
||||
*
|
||||
* <p>This is the value a use case may branch on — {@code "user.active-email.unique"} — as opposed
|
||||
* to the physical index name the server reported. The indirection is what lets an index be renamed,
|
||||
* split into a partial index, or rebuilt concurrently without changing a single line of application
|
||||
* logic.
|
||||
*
|
||||
* <p>The type lives in the framework-free core rather than in the PostgreSQL package because {@link
|
||||
* ConstraintViolationDetails} is a core contract and the core may not depend on a vendor module.
|
||||
* See {@code docs/jpa/repository-adaptation.md} §4.
|
||||
*/
|
||||
public record ConstraintCode(String value) {
|
||||
|
||||
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}");
|
||||
|
||||
public ConstraintCode {
|
||||
if (value == null || !FORMAT.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("invalid constraint code");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* What a constraint violation is allowed to tell the application (design §22.4).
|
||||
*
|
||||
* <p>The {@link ConstraintCode} is the registered, application-owned identity of the rule that was
|
||||
* violated — it is what a use case may branch on. The {@code databaseConstraintName} is the
|
||||
* physical name the server reported; it is optional, bounded, and intended for operators rather
|
||||
* than for business logic, so that renaming an index never silently changes application behaviour.
|
||||
*/
|
||||
public record ConstraintViolationDetails(ConstraintCode code, String databaseConstraintName) {
|
||||
|
||||
/** Physical constraint names come from the server and are bounded before they are exposed. */
|
||||
private static final Pattern DATABASE_NAME = Pattern.compile("[A-Za-z0-9._-]{1,128}");
|
||||
|
||||
/** The code used when the reported constraint is not in the registry. */
|
||||
public static final ConstraintCode UNKNOWN_CODE =
|
||||
new ConstraintCode("database.constraint.unknown");
|
||||
|
||||
public ConstraintViolationDetails {
|
||||
Objects.requireNonNull(code, "code");
|
||||
if (databaseConstraintName != null
|
||||
&& !DATABASE_NAME.matcher(databaseConstraintName).matches()) {
|
||||
databaseConstraintName = JpaFailureContext.REDACTED;
|
||||
}
|
||||
}
|
||||
|
||||
/** A registered violation with no physical constraint name available. */
|
||||
public static ConstraintViolationDetails of(ConstraintCode code) {
|
||||
return new ConstraintViolationDetails(code, null);
|
||||
}
|
||||
|
||||
/** The fallback used when the server reported a constraint the catalog does not know. */
|
||||
public static ConstraintViolationDetails unknown(String databaseConstraintName) {
|
||||
return new ConstraintViolationDetails(UNKNOWN_CODE, databaseConstraintName);
|
||||
}
|
||||
|
||||
/** The physical constraint name, when the server reported a bounded one. */
|
||||
public Optional<String> databaseName() {
|
||||
return Optional.ofNullable(databaseConstraintName);
|
||||
}
|
||||
|
||||
/** Whether this violation was resolved against the registered catalog. */
|
||||
public boolean registered() {
|
||||
return !UNKNOWN_CODE.equals(code);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* A stored value cannot be read back into its declared Java type (design §12).
|
||||
*
|
||||
* <p>The offending value is never included: the whole point of this type is to report the failure
|
||||
* without copying the malformed data into a log.
|
||||
*/
|
||||
public final class DataCorruptionException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public DataCorruptionException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.DATA_CORRUPTION, context, cause);
|
||||
}
|
||||
|
||||
public DataCorruptionException(JpaFailureContext context) {
|
||||
super(FailureCategory.DATA_CORRUPTION, context);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* The server selected this transaction as a deadlock victim and aborted it ({@code 40P01}).
|
||||
*
|
||||
* <p>Unlike a lock timeout, the transaction is already rolled back, so the only safe continuation
|
||||
* is a complete re-run in a new transaction (design §19.1).
|
||||
*/
|
||||
public final class DeadlockDetectedException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public DeadlockDetectedException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.DEADLOCK, context, cause);
|
||||
}
|
||||
|
||||
public DeadlockDetectedException(JpaFailureContext context) {
|
||||
super(FailureCategory.DEADLOCK, context);
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* Provider-independent classification of a persistence failure (design §18.2).
|
||||
*
|
||||
* <p>The category is derived from SQLSTATE and structured server error fields, never from parsing a
|
||||
* localized message. A state the platform does not recognise stays {@link #UNKNOWN}; it is not
|
||||
* optimistically folded into a retryable category, because guessing here is what turns a
|
||||
* non-idempotent write into a duplicate.
|
||||
*/
|
||||
public enum FailureCategory {
|
||||
|
||||
/** {@code 40001} — the transaction lost a serialization race and may be re-run. */
|
||||
SERIALIZATION_FAILURE,
|
||||
|
||||
/** {@code 40003} — the server could not report whether the statement completed. */
|
||||
COMPLETION_UNKNOWN,
|
||||
|
||||
/** {@code 40P01} — the server chose this transaction as the deadlock victim. */
|
||||
DEADLOCK,
|
||||
|
||||
/** {@code 23505} — a unique or exclusion constraint rejected the write. */
|
||||
UNIQUE_CONSTRAINT,
|
||||
|
||||
/** {@code 23503} — a foreign key constraint rejected the write. */
|
||||
FOREIGN_KEY_CONSTRAINT,
|
||||
|
||||
/** {@code 23514} — a check constraint rejected the write. */
|
||||
CHECK_CONSTRAINT,
|
||||
|
||||
/** {@code 23502} — a not-null constraint rejected the write. */
|
||||
NOT_NULL_CONSTRAINT,
|
||||
|
||||
/** {@code 55P03} — a lock could not be acquired within the configured bound. */
|
||||
LOCK_NOT_AVAILABLE,
|
||||
|
||||
/** An optimistic {@code @Version} check failed at flush or commit. */
|
||||
OPTIMISTIC_CONFLICT,
|
||||
|
||||
/** A statement exceeded its configured statement timeout. */
|
||||
QUERY_TIMEOUT,
|
||||
|
||||
/** A transaction exceeded its configured transaction timeout. */
|
||||
TRANSACTION_TIMEOUT,
|
||||
|
||||
/** A connection could not be obtained or was lost outside the commit phase. */
|
||||
CONNECTION_UNAVAILABLE,
|
||||
|
||||
/** The physical schema does not match what the provider or migration gate requires. */
|
||||
SCHEMA_MISMATCH,
|
||||
|
||||
/** A stored value cannot be read back into its declared Java type. */
|
||||
DATA_CORRUPTION,
|
||||
|
||||
/** A required row was absent where the use case requires it to exist. */
|
||||
ENTITY_NOT_FOUND,
|
||||
|
||||
/** The SQLSTATE is not registered; the platform refuses to guess a disposition. */
|
||||
UNKNOWN
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A foreign key constraint rejected the write ({@code 23503}).
|
||||
*
|
||||
* <p>Never retryable: the referenced row's absence is a state fact, not a transient race.
|
||||
*/
|
||||
public final class ForeignKeyViolationException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient ConstraintViolationDetails details;
|
||||
|
||||
public ForeignKeyViolationException(
|
||||
JpaFailureContext context, ConstraintViolationDetails details, Throwable cause) {
|
||||
super(FailureCategory.FOREIGN_KEY_CONSTRAINT, contextWithConstraint(context, details), cause);
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public ForeignKeyViolationException(
|
||||
JpaFailureContext context, ConstraintViolationDetails details) {
|
||||
this(context, details, null);
|
||||
}
|
||||
|
||||
/** The registered constraint code, and the bounded physical name when the server reported one. */
|
||||
public ConstraintViolationDetails details() {
|
||||
return details;
|
||||
}
|
||||
|
||||
private static JpaFailureContext contextWithConstraint(
|
||||
JpaFailureContext context, ConstraintViolationDetails details) {
|
||||
Objects.requireNonNull(details, "details");
|
||||
return details.databaseName().map(context::withConstraintName).orElse(context);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* A row the use case requires does not exist (design §18).
|
||||
*
|
||||
* <p>The missing identifier is not part of the message; the operation name is what identifies the
|
||||
* lookup that failed.
|
||||
*/
|
||||
public final class JpaEntityNotFoundException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public JpaEntityNotFoundException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.ENTITY_NOT_FOUND, context, cause);
|
||||
}
|
||||
|
||||
public JpaEntityNotFoundException(JpaFailureContext context) {
|
||||
super(FailureCategory.ENTITY_NOT_FOUND, context);
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The bounded metadata every stable persistence failure carries (design §18.1).
|
||||
*
|
||||
* <p>Every component is either a registered identity, a SQLSTATE, a counter, a boolean, or a
|
||||
* duration. No component may carry a SQL parameter value, an entity id, a tenant id, the SQL text,
|
||||
* or PII — this record is what reaches logs, metrics, and error responses.
|
||||
*
|
||||
* <p>The record enforces one invariant that the rest of the platform depends on: a failure whose
|
||||
* completion is unknown is never {@code retryable}. Automatically re-running work whose commit may
|
||||
* already have succeeded is the single most damaging thing this platform could do, so the type
|
||||
* system refuses to represent it (design §17.4, §19.1).
|
||||
*/
|
||||
public record JpaFailureContext(
|
||||
PersistenceOperationName operation,
|
||||
String sqlState,
|
||||
String constraintName,
|
||||
int transactionAttempt,
|
||||
boolean retryable,
|
||||
boolean completionUnknown,
|
||||
Duration elapsed,
|
||||
String traceId) {
|
||||
|
||||
/** SQLSTATE is five alphanumeric characters; anything else is not a SQLSTATE. */
|
||||
private static final Pattern SQL_STATE = Pattern.compile("[0-9A-Za-z]{5}");
|
||||
|
||||
/** Constraint and trace identifiers are bounded to keep diagnostics free of free-form text. */
|
||||
private static final Pattern BOUNDED_IDENTIFIER = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||
|
||||
/** Substituted for any identifier that is not provably bounded. */
|
||||
public static final String REDACTED = "redacted";
|
||||
|
||||
/** Used when the driver reported no SQLSTATE at all. */
|
||||
public static final String NO_SQL_STATE = "00000";
|
||||
|
||||
public JpaFailureContext {
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
Objects.requireNonNull(elapsed, "elapsed");
|
||||
if (elapsed.isNegative()) {
|
||||
throw new IllegalArgumentException("elapsed must not be negative");
|
||||
}
|
||||
if (transactionAttempt < 1) {
|
||||
throw new IllegalArgumentException("transactionAttempt must be at least 1");
|
||||
}
|
||||
if (completionUnknown && retryable) {
|
||||
throw new IllegalArgumentException("completion unknown failures are never retryable");
|
||||
}
|
||||
sqlState = normalizeSqlState(sqlState);
|
||||
constraintName = normalizeOptionalIdentifier(constraintName);
|
||||
traceId = normalizeOptionalIdentifier(traceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure the platform may re-run as a whole new transaction.
|
||||
*
|
||||
* @param attempt the 1-based attempt that produced this failure
|
||||
*/
|
||||
public static JpaFailureContext retryable(
|
||||
PersistenceOperationName operation,
|
||||
String sqlState,
|
||||
int attempt,
|
||||
Duration elapsed,
|
||||
String traceId) {
|
||||
return new JpaFailureContext(operation, sqlState, null, attempt, true, false, elapsed, traceId);
|
||||
}
|
||||
|
||||
/** A failure the platform must surface rather than re-run. */
|
||||
public static JpaFailureContext terminal(
|
||||
PersistenceOperationName operation,
|
||||
String sqlState,
|
||||
int attempt,
|
||||
Duration elapsed,
|
||||
String traceId) {
|
||||
return new JpaFailureContext(
|
||||
operation, sqlState, null, attempt, false, false, elapsed, traceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure whose commit outcome the driver could not determine (design §17.2).
|
||||
*
|
||||
* <p>The result is always {@code retryable=false} and {@code completionUnknown=true}; there is no
|
||||
* argument that lets a caller weaken either.
|
||||
*/
|
||||
public static JpaFailureContext completionUnknown(
|
||||
PersistenceOperationName operation,
|
||||
String sqlState,
|
||||
int attempt,
|
||||
Duration elapsed,
|
||||
String traceId) {
|
||||
return new JpaFailureContext(operation, sqlState, null, attempt, false, true, elapsed, traceId);
|
||||
}
|
||||
|
||||
/** Returns a copy that also carries the bounded database constraint name. */
|
||||
public JpaFailureContext withConstraintName(String databaseConstraintName) {
|
||||
return new JpaFailureContext(
|
||||
operation,
|
||||
sqlState,
|
||||
databaseConstraintName,
|
||||
transactionAttempt,
|
||||
retryable,
|
||||
completionUnknown,
|
||||
elapsed,
|
||||
traceId);
|
||||
}
|
||||
|
||||
/** Returns a copy recorded against a later attempt of the same logical operation. */
|
||||
public JpaFailureContext withAttempt(int attempt) {
|
||||
return new JpaFailureContext(
|
||||
operation,
|
||||
sqlState,
|
||||
constraintName,
|
||||
attempt,
|
||||
retryable,
|
||||
completionUnknown,
|
||||
elapsed,
|
||||
traceId);
|
||||
}
|
||||
|
||||
/** Whether a bounded database constraint name is present. */
|
||||
public boolean hasConstraintName() {
|
||||
return !constraintName.isEmpty();
|
||||
}
|
||||
|
||||
private static String normalizeSqlState(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return NO_SQL_STATE;
|
||||
}
|
||||
String trimmed = candidate.trim();
|
||||
return SQL_STATE.matcher(trimmed).matches() ? trimmed : REDACTED;
|
||||
}
|
||||
|
||||
private static String normalizeOptionalIdentifier(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = candidate.trim();
|
||||
return BOUNDED_IDENTIFIER.matcher(trimmed).matches() ? trimmed : REDACTED;
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Root of the provider-independent persistence error hierarchy (design §18).
|
||||
*
|
||||
* <p>The message is composed by this class from the bounded parts of {@link JpaFailureContext} and
|
||||
* a fixed category label. Subclasses do not pass free-form text, which is what keeps SQL parameter
|
||||
* values, entity ids, and PII out of every log line, error response, and metric derived from these
|
||||
* exceptions. The provider exception is preserved as the {@linkplain #getCause() cause} for
|
||||
* in-process classification and server-side diagnosis only.
|
||||
*/
|
||||
public class JpaPersistenceException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient JpaFailureContext context;
|
||||
private final transient FailureCategory category;
|
||||
|
||||
public JpaPersistenceException(
|
||||
FailureCategory category, JpaFailureContext context, Throwable cause) {
|
||||
super(describe(category, context), cause);
|
||||
this.category = Objects.requireNonNull(category, "category");
|
||||
this.context = Objects.requireNonNull(context, "context");
|
||||
}
|
||||
|
||||
public JpaPersistenceException(FailureCategory category, JpaFailureContext context) {
|
||||
this(category, context, null);
|
||||
}
|
||||
|
||||
/** The bounded metadata carried by this failure. */
|
||||
public JpaFailureContext context() {
|
||||
return context;
|
||||
}
|
||||
|
||||
/** The provider-independent classification of this failure. */
|
||||
public FailureCategory category() {
|
||||
return category;
|
||||
}
|
||||
|
||||
/** Whether the platform may re-run the whole use case for this failure. */
|
||||
public boolean retryable() {
|
||||
return context.retryable();
|
||||
}
|
||||
|
||||
/** Whether the commit outcome of the failing transaction is undetermined. */
|
||||
public boolean completionUnknown() {
|
||||
return context.completionUnknown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the exception message from bounded values only.
|
||||
*
|
||||
* <p>Every fragment below is a registered operation name, a validated SQLSTATE, a bounded
|
||||
* identifier, an enum constant, an int, or a boolean. Nothing here can originate from row data.
|
||||
*/
|
||||
private static String describe(FailureCategory category, JpaFailureContext context) {
|
||||
StringBuilder message = new StringBuilder(160);
|
||||
message
|
||||
.append("jpa persistence failure [")
|
||||
.append(category)
|
||||
.append("] operation=")
|
||||
.append(context.operation().value())
|
||||
.append(" sqlState=")
|
||||
.append(context.sqlState())
|
||||
.append(" attempt=")
|
||||
.append(context.transactionAttempt())
|
||||
.append(" retryable=")
|
||||
.append(context.retryable())
|
||||
.append(" completionUnknown=")
|
||||
.append(context.completionUnknown())
|
||||
.append(" elapsedMillis=")
|
||||
.append(context.elapsed().toMillis());
|
||||
if (context.hasConstraintName()) {
|
||||
message.append(" constraint=").append(context.constraintName());
|
||||
}
|
||||
if (!context.traceId().isEmpty()) {
|
||||
message.append(" traceId=").append(context.traceId());
|
||||
}
|
||||
return message.toString();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A not-null constraint rejected the write ({@code 23502}, design §18.2).
|
||||
*
|
||||
* <p>The column name reaches the application only through the registered constraint catalog, so a
|
||||
* schema rename cannot change what the application branches on.
|
||||
*/
|
||||
public final class NotNullConstraintViolationException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient ConstraintViolationDetails details;
|
||||
|
||||
public NotNullConstraintViolationException(
|
||||
JpaFailureContext context, ConstraintViolationDetails details, Throwable cause) {
|
||||
super(FailureCategory.NOT_NULL_CONSTRAINT, contextWithConstraint(context, details), cause);
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public NotNullConstraintViolationException(
|
||||
JpaFailureContext context, ConstraintViolationDetails details) {
|
||||
this(context, details, null);
|
||||
}
|
||||
|
||||
/** The registered constraint code, and the bounded physical name when the server reported one. */
|
||||
public ConstraintViolationDetails details() {
|
||||
return details;
|
||||
}
|
||||
|
||||
private static JpaFailureContext contextWithConstraint(
|
||||
JpaFailureContext context, ConstraintViolationDetails details) {
|
||||
Objects.requireNonNull(details, "details");
|
||||
return details.databaseName().map(context::withConstraintName).orElse(context);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* An optimistic {@code @Version} check failed at flush or commit (design §20).
|
||||
*
|
||||
* <p>The conflicting entity type is carried by the observation layer through a bounded catalog; the
|
||||
* entity id is deliberately absent, because it is row data.
|
||||
*/
|
||||
public final class OptimisticConflictException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public OptimisticConflictException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.OPTIMISTIC_CONFLICT, context, cause);
|
||||
}
|
||||
|
||||
public OptimisticConflictException(JpaFailureContext context) {
|
||||
super(FailureCategory.OPTIMISTIC_CONFLICT, context);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* A pessimistic lock could not be acquired within its bound, or {@code NOWAIT} refused it
|
||||
* immediately (design §21.1, §21.2).
|
||||
*
|
||||
* <p>This is a statement-level outcome: the transaction is still the caller's to end. It is
|
||||
* distinct from {@link DeadlockDetectedException}, which the server has already aborted.
|
||||
*/
|
||||
public final class PessimisticLockTimeoutException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public PessimisticLockTimeoutException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.LOCK_NOT_AVAILABLE, context, cause);
|
||||
}
|
||||
|
||||
public PessimisticLockTimeoutException(JpaFailureContext context) {
|
||||
super(FailureCategory.LOCK_NOT_AVAILABLE, context);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* A statement exceeded its configured statement timeout.
|
||||
*
|
||||
* <p>Not retryable by default: a query that ran out of time usually costs the same the second time,
|
||||
* and re-running it doubles the load that caused the timeout (design §19.1).
|
||||
*/
|
||||
public final class QueryTimeoutException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public QueryTimeoutException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.QUERY_TIMEOUT, context, cause);
|
||||
}
|
||||
|
||||
public QueryTimeoutException(JpaFailureContext context) {
|
||||
super(FailureCategory.QUERY_TIMEOUT, context);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* The physical schema does not match what Hibernate validate or the Flyway gate requires (design
|
||||
* §31).
|
||||
*
|
||||
* <p>Fail-closed and never retryable: the deployment is running against a schema it was not built
|
||||
* for, and a second attempt cannot change that.
|
||||
*/
|
||||
public final class SchemaMismatchException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public SchemaMismatchException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.SCHEMA_MISMATCH, context, cause);
|
||||
}
|
||||
|
||||
public SchemaMismatchException(JpaFailureContext context) {
|
||||
super(FailureCategory.SCHEMA_MISMATCH, context);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* The transaction lost a serialization race ({@code 40001}) and was rolled back.
|
||||
*
|
||||
* <p>Bounded full-transaction retry is the designed response, because the whole use case must be
|
||||
* recomputed against the committed state that won (design §19.1).
|
||||
*/
|
||||
public final class SerializationFailureException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public SerializationFailureException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.SERIALIZATION_FAILURE, context, cause);
|
||||
}
|
||||
|
||||
public SerializationFailureException(JpaFailureContext context) {
|
||||
super(FailureCategory.SERIALIZATION_FAILURE, context);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The vendor-neutral {@link SqlStateResolver}: walks the cause chain for a {@link SQLException} and
|
||||
* reads its {@linkplain SQLException#getSQLState() SQLSTATE}.
|
||||
*
|
||||
* <p>{@link SQLException#getNextException()} is followed as well as {@link Throwable#getCause()}.
|
||||
* Drivers chain batch failures through {@code getNextException}, and the state that explains the
|
||||
* failure is frequently on the second link rather than the first.
|
||||
*
|
||||
* <p>Traversal is cycle-safe. Provider exception chains are assembled by several layers — Spring,
|
||||
* Hibernate, JDBC, the driver — and a chain that loops back on itself would otherwise hang the
|
||||
* thread that is trying to report an error.
|
||||
*/
|
||||
public final class SqlExceptionSqlStateResolver implements SqlStateResolver {
|
||||
|
||||
private static final int MAX_DEPTH = 64;
|
||||
|
||||
@Override
|
||||
public Optional<String> resolve(Throwable failure) {
|
||||
IdentityHashMap<Throwable, Boolean> seen = new IdentityHashMap<>();
|
||||
Throwable current = failure;
|
||||
int depth = 0;
|
||||
while (current != null && depth++ < MAX_DEPTH && seen.put(current, Boolean.TRUE) == null) {
|
||||
if (current instanceof SQLException sqlFailure) {
|
||||
String state = sqlFailure.getSQLState();
|
||||
if (state != null && !state.isBlank()) {
|
||||
return Optional.of(state.trim());
|
||||
}
|
||||
SQLException next = sqlFailure.getNextException();
|
||||
if (next != null && !seen.containsKey(next)) {
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Extracts the SQLSTATE from a provider exception chain (design §18.2).
|
||||
*
|
||||
* <p>This is an SPI so the transaction module never depends on a vendor package: the commit-phase
|
||||
* classifier needs a SQLSTATE, not a PostgreSQL driver. Implementations must read structured driver
|
||||
* fields and must never parse a localized message, which changes with server locale and version.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface SqlStateResolver {
|
||||
|
||||
/** The SQLSTATE this failure carries, when the chain exposes one. */
|
||||
Optional<String> resolve(Throwable failure);
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The commit outcome of a transaction could not be determined (design §17.3).
|
||||
*
|
||||
* <p>This exception is the one the whole retry design exists to protect: it is <em>never</em>
|
||||
* retryable and never automatically re-run. The write may already be committed on the server, so a
|
||||
* second attempt would be a duplicate rather than a repair. The invariant is enforced twice — the
|
||||
* constructor rebuilds the context through {@link JpaFailureContext#completionUnknown}, and {@link
|
||||
* JpaFailureContext} itself refuses to represent a retryable completion-unknown failure.
|
||||
*
|
||||
* <p>Recovery is reconciliation, not retry: look the {@link #transactionKey()} up against the
|
||||
* idempotency record, the business row, and the outbox, and enqueue it when the answer is still
|
||||
* undetermined (design §17.4).
|
||||
*/
|
||||
public final class TransactionCompletionUnknownException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Transaction keys are application-chosen correlation ids, bounded before they are exposed. */
|
||||
private static final Pattern TRANSACTION_KEY = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||
|
||||
private final transient String transactionKey;
|
||||
private final transient TransactionCompletionEvidence evidence;
|
||||
|
||||
public TransactionCompletionUnknownException(
|
||||
JpaFailureContext context,
|
||||
String transactionKey,
|
||||
TransactionCompletionEvidence evidence,
|
||||
Throwable cause) {
|
||||
super(FailureCategory.COMPLETION_UNKNOWN, forceCompletionUnknown(context), cause);
|
||||
this.transactionKey = normalizeKey(transactionKey);
|
||||
this.evidence = Objects.requireNonNull(evidence, "evidence");
|
||||
}
|
||||
|
||||
public TransactionCompletionUnknownException(
|
||||
JpaFailureContext context, TransactionCompletionEvidence evidence, Throwable cause) {
|
||||
this(context, null, evidence, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* The application-supplied correlation key used to reconcile this transaction, when one was bound
|
||||
* to the unit of work.
|
||||
*/
|
||||
public Optional<String> transactionKey() {
|
||||
return transactionKey.isEmpty() ? Optional.empty() : Optional.of(transactionKey);
|
||||
}
|
||||
|
||||
/** The furthest phase the platform could prove the transaction reached. */
|
||||
public TransactionCompletionEvidence evidence() {
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static JpaFailureContext forceCompletionUnknown(JpaFailureContext context) {
|
||||
Objects.requireNonNull(context, "context");
|
||||
if (context.completionUnknown() && !context.retryable()) {
|
||||
return context;
|
||||
}
|
||||
JpaFailureContext forced =
|
||||
JpaFailureContext.completionUnknown(
|
||||
context.operation(),
|
||||
context.sqlState(),
|
||||
context.transactionAttempt(),
|
||||
context.elapsed(),
|
||||
context.traceId());
|
||||
return context.hasConstraintName()
|
||||
? forced.withConstraintName(context.constraintName())
|
||||
: forced;
|
||||
}
|
||||
|
||||
private static String normalizeKey(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = candidate.trim();
|
||||
return TRANSACTION_KEY.matcher(trimmed).matches() ? trimmed : JpaFailureContext.REDACTED;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
/**
|
||||
* A transaction exceeded its configured transaction timeout (design §15.4).
|
||||
*
|
||||
* <p>The transaction is rolled back; whether the use case may be re-run is a business decision, so
|
||||
* the platform does not retry it automatically.
|
||||
*/
|
||||
public final class TransactionTimeoutException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public TransactionTimeoutException(JpaFailureContext context, Throwable cause) {
|
||||
super(FailureCategory.TRANSACTION_TIMEOUT, context, cause);
|
||||
}
|
||||
|
||||
public TransactionTimeoutException(JpaFailureContext context) {
|
||||
super(FailureCategory.TRANSACTION_TIMEOUT, context);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.error;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A unique or exclusion constraint rejected the write ({@code 23505}).
|
||||
*
|
||||
* <p>This is the designed final arbiter of a create race: two concurrent inserts of the same
|
||||
* logical key produce one commit and one of these, without a preceding {@code exists} query (design
|
||||
* §22).
|
||||
*/
|
||||
public final class UniqueConstraintViolationException extends JpaPersistenceException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient ConstraintViolationDetails details;
|
||||
|
||||
public UniqueConstraintViolationException(
|
||||
JpaFailureContext context, ConstraintViolationDetails details, Throwable cause) {
|
||||
super(FailureCategory.UNIQUE_CONSTRAINT, contextWithConstraint(context, details), cause);
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public UniqueConstraintViolationException(
|
||||
JpaFailureContext context, ConstraintViolationDetails details) {
|
||||
this(context, details, null);
|
||||
}
|
||||
|
||||
/** The registered constraint code, and the bounded physical name when the server reported one. */
|
||||
public ConstraintViolationDetails details() {
|
||||
return details;
|
||||
}
|
||||
|
||||
private static JpaFailureContext contextWithConstraint(
|
||||
JpaFailureContext context, ConstraintViolationDetails details) {
|
||||
Objects.requireNonNull(details, "details");
|
||||
return details.databaseName().map(context::withConstraintName).orElse(context);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
/**
|
||||
* Encodes and decodes an opaque page cursor (design §27.3).
|
||||
*
|
||||
* <p>A cursor crosses the trust boundary: it is handed to a client and comes back. An
|
||||
* implementation must therefore treat {@link #decode(String)} input as hostile and reject anything
|
||||
* it did not produce, rather than parsing whatever arrives.
|
||||
*/
|
||||
public interface CursorCodec<C> {
|
||||
|
||||
/** Encodes a cursor into a client-safe, tamper-evident token. */
|
||||
String encode(C cursor);
|
||||
|
||||
/**
|
||||
* Decodes a token this codec produced.
|
||||
*
|
||||
* @throws IllegalArgumentException if the token is malformed, of an unknown version, or its
|
||||
* signature does not verify
|
||||
*/
|
||||
C decode(String encoded);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
/**
|
||||
* The application-supplied conversion between a cursor value and its JSON payload (design §27.3).
|
||||
*
|
||||
* <p>This seam is why {@link SignedJsonCursorCodec} needs no JSON library: the core contract stays
|
||||
* on the Java standard library, and the application plugs in whichever mapper it already uses.
|
||||
*
|
||||
* <p>Implementations must serialise only the ordering key and its tie-breakers. Putting JPQL, SQL
|
||||
* fragments, entity paths, or filter state into the payload turns an opaque cursor into a
|
||||
* client-controlled query.
|
||||
*/
|
||||
public interface CursorPayloadCodec<C> {
|
||||
|
||||
/** Serialises the cursor's ordering key and tie-breakers to JSON. */
|
||||
String toJson(C cursor);
|
||||
|
||||
/**
|
||||
* Reconstructs a cursor from a payload this codec produced.
|
||||
*
|
||||
* @throws IllegalArgumentException if the payload does not describe a valid cursor
|
||||
*/
|
||||
C fromJson(String json);
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* One page of a keyset scan (design §27.2).
|
||||
*
|
||||
* <p>The page size is bounded at construction. An unbounded page is how a "just fetch everything"
|
||||
* call reaches production, and by the time it is noticed it is holding a connection open over a
|
||||
* table that has grown.
|
||||
*
|
||||
* <p>{@code after} is empty for the first page. There is no offset: that is the point of keyset
|
||||
* pagination, and the absence of the field is what stops one being added later.
|
||||
*/
|
||||
public record KeysetPageRequest<C>(Optional<C> after, int size, SortDirection direction) {
|
||||
|
||||
/** The largest page any keyset request may ask for. */
|
||||
public static final int MAX_SIZE = 500;
|
||||
|
||||
public KeysetPageRequest {
|
||||
Objects.requireNonNull(after, "after");
|
||||
Objects.requireNonNull(direction, "direction");
|
||||
if (size < 1 || size > MAX_SIZE) {
|
||||
throw new IllegalArgumentException("invalid page size");
|
||||
}
|
||||
}
|
||||
|
||||
/** The first page of a descending scan — the common "most recent first" case. */
|
||||
public static <C> KeysetPageRequest<C> first(int size) {
|
||||
return new KeysetPageRequest<>(Optional.empty(), size, SortDirection.DESCENDING);
|
||||
}
|
||||
|
||||
/** The page that follows {@code cursor} in the same direction and of the same size. */
|
||||
public KeysetPageRequest<C> after(C cursor) {
|
||||
return new KeysetPageRequest<>(
|
||||
Optional.of(Objects.requireNonNull(cursor, "cursor")), size, direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows to actually fetch: one more than requested.
|
||||
*
|
||||
* <p>The extra row is how {@code hasNext} is answered without a count query (design §27.2).
|
||||
*/
|
||||
public int fetchSize() {
|
||||
return size + 1;
|
||||
}
|
||||
|
||||
/** Whether this is the first page of the scan. */
|
||||
public boolean isFirstPage() {
|
||||
return after.isEmpty();
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* One returned page of a keyset scan (design §27.2).
|
||||
*
|
||||
* <p>There is deliberately no total count and no page number. Producing either requires a second
|
||||
* aggregate query over the same predicate, which is the cost keyset pagination exists to avoid, and
|
||||
* on a moving data set the number is stale before it reaches the client.
|
||||
*/
|
||||
public record KeysetSlice<T, C>(List<T> items, Optional<C> nextCursor, boolean hasNext) {
|
||||
|
||||
public KeysetSlice {
|
||||
items = List.copyOf(Objects.requireNonNull(items, "items"));
|
||||
Objects.requireNonNull(nextCursor, "nextCursor");
|
||||
if (hasNext && nextCursor.isEmpty()) {
|
||||
throw new IllegalArgumentException("a slice with a next page requires a next cursor");
|
||||
}
|
||||
if (!hasNext && nextCursor.isPresent()) {
|
||||
throw new IllegalArgumentException("a terminal slice must not carry a next cursor");
|
||||
}
|
||||
}
|
||||
|
||||
/** The last page of a scan. */
|
||||
public static <T, C> KeysetSlice<T, C> last(List<T> items) {
|
||||
return new KeysetSlice<>(items, Optional.empty(), false);
|
||||
}
|
||||
|
||||
/** A page followed by at least one more. */
|
||||
public static <T, C> KeysetSlice<T, C> more(List<T> items, C nextCursor) {
|
||||
return new KeysetSlice<>(items, Optional.of(nextCursor), true);
|
||||
}
|
||||
|
||||
/** How many rows this page carries. */
|
||||
public int size() {
|
||||
return items.size();
|
||||
}
|
||||
|
||||
/** Whether this page carries no rows at all. */
|
||||
public boolean isEmpty() {
|
||||
return items.isEmpty();
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
/**
|
||||
* The observation used when no observability module is installed (design §9.5).
|
||||
*
|
||||
* <p>It exists so that call sites never have to null-check an observation, which is how "observe
|
||||
* only when a registry is present" turns into two divergent code paths.
|
||||
*/
|
||||
public final class NoopQueryObservation implements QueryObservation {
|
||||
|
||||
private static final NoopQueryObservation INSTANCE = new NoopQueryObservation();
|
||||
|
||||
private static final QueryScope NOOP_SCOPE =
|
||||
new QueryScope() {
|
||||
@Override
|
||||
public void rows(long count) {
|
||||
// no telemetry backend is installed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void failure(Throwable failure) {
|
||||
// no telemetry backend is installed
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// nothing was opened
|
||||
}
|
||||
};
|
||||
|
||||
private NoopQueryObservation() {}
|
||||
|
||||
/** The shared instance; the type is stateless. */
|
||||
public static NoopQueryObservation instance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryScope start(QueryName queryName) {
|
||||
return NOOP_SCOPE;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Bounded, low-cardinality identity for one registered query (design §9.5).
|
||||
*
|
||||
* <p>The name is what appears in metrics, traces, and the {@code org.hibernate.comment} hint, so it
|
||||
* must be a registry key rather than a description. Raw SQL, JPQL, entity ids, and user input are
|
||||
* rejected by the format: a metric tag built from a query string is unbounded by construction, and
|
||||
* one built from a parameterised value leaks row data into telemetry.
|
||||
*/
|
||||
public record QueryName(String value) {
|
||||
|
||||
/** Design §9.5 — the exact accepted shape of a query name. */
|
||||
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}");
|
||||
|
||||
public QueryName {
|
||||
if (value == null || !FORMAT.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("invalid query name");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
/**
|
||||
* Starts an observation scope for a registered query (design §9.5).
|
||||
*
|
||||
* <p>Framework-neutral on purpose: the core contract must not force a Micrometer or Spring
|
||||
* Observation dependency on modules that only want to name their queries.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface QueryObservation {
|
||||
|
||||
/** Begins observing {@code queryName}; the caller must close the returned scope exactly once. */
|
||||
QueryScope start(QueryName queryName);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
/**
|
||||
* One in-flight observed query (design §9.5).
|
||||
*
|
||||
* <p>The scope is closed exactly once, in a {@code finally} or try-with-resources, whatever the
|
||||
* outcome. {@link #close()} does not declare a checked exception, because a telemetry scope that
|
||||
* forces callers into a second try/catch is a scope people stop closing.
|
||||
*/
|
||||
public interface QueryScope extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* Records how many rows the query actually returned or affected.
|
||||
*
|
||||
* <p>Row count is what separates a genuine N+1 fix from a query that merely issues one statement
|
||||
* and hydrates a Cartesian product (design §25.3).
|
||||
*/
|
||||
void rows(long count);
|
||||
|
||||
/** Records that the query failed. Implementations must not log the throwable's message. */
|
||||
void failure(Throwable failure);
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* A versioned, tamper-evident cursor codec built only on the Java standard library (design §27.3).
|
||||
*
|
||||
* <p>The encoded form is {@code <version>.<base64url(payload)>.<base64url(mac)>}. The MAC covers
|
||||
* the version and the payload together, so an attacker cannot downgrade a token to an older cursor
|
||||
* format by rewriting the prefix.
|
||||
*
|
||||
* <p>Signing a cursor is not about confidentiality — the payload is readable — it is about
|
||||
* integrity. An unsigned cursor is client-controlled ordering state: rewriting it lets a caller
|
||||
* seek to arbitrary keys, which turns a paging token into an access-control bypass wherever the
|
||||
* predicate depends on where the scan started.
|
||||
*
|
||||
* <p>Verification is constant-time via {@link MessageDigest#isEqual}. A short-circuiting comparison
|
||||
* here leaks the correct MAC one byte at a time.
|
||||
*/
|
||||
public final class SignedJsonCursorCodec<C> implements CursorCodec<C> {
|
||||
|
||||
/** The only cursor version this codec issues and accepts. */
|
||||
public static final String VERSION = "v1";
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
private static final int MINIMUM_KEY_LENGTH = 32;
|
||||
private static final char SEPARATOR = '.';
|
||||
|
||||
private final CursorPayloadCodec<C> payloadCodec;
|
||||
private final byte[] key;
|
||||
private final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
|
||||
private final Base64.Decoder decoder = Base64.getUrlDecoder();
|
||||
|
||||
/**
|
||||
* @param payloadCodec the application's cursor-to-JSON conversion
|
||||
* @param key the HMAC key; at least 32 bytes, and never derived from a configuration default
|
||||
*/
|
||||
public SignedJsonCursorCodec(CursorPayloadCodec<C> payloadCodec, byte[] key) {
|
||||
this.payloadCodec = Objects.requireNonNull(payloadCodec, "payloadCodec");
|
||||
Objects.requireNonNull(key, "key");
|
||||
if (key.length < MINIMUM_KEY_LENGTH) {
|
||||
throw new IllegalArgumentException("cursor signing key must be at least 32 bytes");
|
||||
}
|
||||
this.key = key.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String encode(C cursor) {
|
||||
Objects.requireNonNull(cursor, "cursor");
|
||||
String payload =
|
||||
encoder.encodeToString(payloadCodec.toJson(cursor).getBytes(StandardCharsets.UTF_8));
|
||||
String signed = VERSION + SEPARATOR + payload;
|
||||
return signed + SEPARATOR + encoder.encodeToString(mac(signed));
|
||||
}
|
||||
|
||||
@Override
|
||||
public C decode(String encoded) {
|
||||
if (encoded == null || encoded.isBlank()) {
|
||||
throw new IllegalArgumentException("cursor must not be blank");
|
||||
}
|
||||
int payloadSeparator = encoded.indexOf(SEPARATOR);
|
||||
int macSeparator = encoded.lastIndexOf(SEPARATOR);
|
||||
if (payloadSeparator <= 0 || macSeparator <= payloadSeparator) {
|
||||
throw new IllegalArgumentException("malformed cursor");
|
||||
}
|
||||
String version = encoded.substring(0, payloadSeparator);
|
||||
if (!VERSION.equals(version)) {
|
||||
throw new IllegalArgumentException("unknown cursor version");
|
||||
}
|
||||
String signed = encoded.substring(0, macSeparator);
|
||||
byte[] presented = decodeBase64(encoded.substring(macSeparator + 1));
|
||||
if (!MessageDigest.isEqual(mac(signed), presented)) {
|
||||
throw new IllegalArgumentException("cursor signature does not verify");
|
||||
}
|
||||
byte[] payload = decodeBase64(encoded.substring(payloadSeparator + 1, macSeparator));
|
||||
return payloadCodec.fromJson(new String(payload, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private byte[] decodeBase64(String value) {
|
||||
try {
|
||||
return decoder.decode(value);
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
throw new IllegalArgumentException("malformed cursor", malformed);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] mac(String signed) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM);
|
||||
mac.init(new SecretKeySpec(key, ALGORITHM));
|
||||
return mac.doFinal(signed.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (GeneralSecurityException unavailable) {
|
||||
throw new IllegalStateException("cursor signing algorithm is unavailable", unavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.query;
|
||||
|
||||
/**
|
||||
* Direction of a keyset scan (design §27.2).
|
||||
*
|
||||
* <p>Keyset pagination requires the direction to be part of the request rather than inferred from
|
||||
* the cursor, because the comparison operator and the tie-breaker ordering must both follow it.
|
||||
*/
|
||||
public enum SortDirection {
|
||||
ASCENDING,
|
||||
DESCENDING;
|
||||
|
||||
/** The direction that walks the same ordering backwards. */
|
||||
public SortDirection reversed() {
|
||||
return this == ASCENDING ? DESCENDING : ASCENDING;
|
||||
}
|
||||
|
||||
/** Whether this direction scans from smaller to larger keys. */
|
||||
public boolean ascending() {
|
||||
return this == ASCENDING;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
/**
|
||||
* The isolation levels the Stable platform exposes (design §9.2, §16.2).
|
||||
*
|
||||
* <p>{@code READ_UNCOMMITTED} is absent: PostgreSQL treats it as {@code READ COMMITTED}, so
|
||||
* offering it would let a profile claim an isolation the database never provides.
|
||||
*/
|
||||
public enum IsolationLevel {
|
||||
|
||||
/** Whatever the connection is configured with; on PostgreSQL that is read committed. */
|
||||
DEFAULT,
|
||||
|
||||
/** The Stable default for both read and write profiles. */
|
||||
READ_COMMITTED,
|
||||
|
||||
/** Snapshot-stable reads within the transaction. */
|
||||
REPEATABLE_READ,
|
||||
|
||||
/** Full serializability; expect {@code 40001} and pair it with a retry profile. */
|
||||
SERIALIZABLE
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
/**
|
||||
* How retry backoff is randomised (design §19.3).
|
||||
*
|
||||
* <p>Jitter is not cosmetic here: without it, every contender in a deadlock or serialization storm
|
||||
* recomputes the same backoff and collides again at the same instant.
|
||||
*/
|
||||
public enum JitterMode {
|
||||
|
||||
/** Deterministic backoff. Only appropriate for tests and single-writer work. */
|
||||
NONE,
|
||||
|
||||
/** Uniform in {@code [0, backoff]} — the strongest de-synchronisation. */
|
||||
FULL,
|
||||
|
||||
/** Uniform in {@code [backoff/2, backoff]} — keeps a floor under the wait. */
|
||||
EQUAL
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException;
|
||||
|
||||
/**
|
||||
* Decides what may be done about one failed attempt (design §9.4).
|
||||
*
|
||||
* <p>A policy classifies; it never executes. Keeping the decision separate from the retry loop is
|
||||
* what makes "completion unknown is never retried" a property that can be unit-tested without a
|
||||
* database.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface JpaRetryPolicy {
|
||||
|
||||
/**
|
||||
* Classifies a failure into a disposition, a backoff, and a bounded reason.
|
||||
*
|
||||
* @param failure the stable failure produced by the exception translator
|
||||
* @param attempt the 1-based attempt that produced it
|
||||
*/
|
||||
RetryDecision classify(JpaPersistenceException failure, TransactionAttempt attempt);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Runs one unit of work inside exactly one transaction described by a profile (design §9.3).
|
||||
*
|
||||
* <p>This interface owns the transaction boundary and nothing else. Retry is deliberately not part
|
||||
* of it: a retry that reused the same executor call would reuse the same Persistence Context, which
|
||||
* is precisely the bug the design forbids. The retry coordinator calls this interface again,
|
||||
* getting a new transaction and a new context each time.
|
||||
*/
|
||||
public interface JpaTransactionExecutor {
|
||||
|
||||
/**
|
||||
* Executes {@code work} inside one transaction configured by {@code profile}.
|
||||
*
|
||||
* @param operation the registered, bounded identity used for observation and policy lookup
|
||||
* @param profile propagation, isolation, timeout, and read-only for this single transaction
|
||||
* @param work the unit of work; it must be safe to run again from scratch in a new transaction
|
||||
*/
|
||||
<T> T execute(PersistenceOperationName operation, TransactionProfile profile, Supplier<T> work);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
/**
|
||||
* The propagation values the Stable platform supports (design §9.2, §16.1).
|
||||
*
|
||||
* <p>The list is deliberately short. {@code NESTED}, {@code SUPPORTS}, {@code NOT_SUPPORTED}, and
|
||||
* {@code NEVER} are absent because each one silently changes whether the caller's work is inside a
|
||||
* transaction at all, which is the kind of ambiguity this platform exists to remove.
|
||||
*/
|
||||
public enum PropagationMode {
|
||||
|
||||
/** Join the caller's transaction, or start one. The Stable default. */
|
||||
REQUIRED,
|
||||
|
||||
/** Require the caller to already own a transaction; refuse to start one. */
|
||||
MANDATORY,
|
||||
|
||||
/**
|
||||
* Suspend the caller's transaction and run in a new one.
|
||||
*
|
||||
* <p>Opt-in only: it acquires a second physical connection while pinning the first, so a profile
|
||||
* using it must be paired with the pool-pressure evidence in design §38.
|
||||
*/
|
||||
REQUIRES_NEW
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The outcome of classifying one failed attempt (design §9.4).
|
||||
*
|
||||
* <p>The {@code reason} is a bounded diagnostic string chosen by the policy, never a provider
|
||||
* message, so it is safe to log and to use as a low-cardinality metric tag.
|
||||
*/
|
||||
public record RetryDecision(RetryDisposition disposition, Duration delay, String reason) {
|
||||
|
||||
public RetryDecision {
|
||||
Objects.requireNonNull(disposition, "disposition");
|
||||
Objects.requireNonNull(delay, "delay");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (delay.isNegative()) {
|
||||
throw new IllegalArgumentException("retry delay must not be negative");
|
||||
}
|
||||
if (disposition != RetryDisposition.RETRY_FULL_TRANSACTION && !delay.isZero()) {
|
||||
throw new IllegalArgumentException("only a full transaction retry may carry a delay");
|
||||
}
|
||||
if (reason.isBlank()) {
|
||||
throw new IllegalArgumentException("retry decision requires a reason");
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-run the whole use case after the supplied backoff. */
|
||||
public static RetryDecision retry(Duration delay) {
|
||||
return new RetryDecision(
|
||||
RetryDisposition.RETRY_FULL_TRANSACTION, delay, "retryable persistence failure");
|
||||
}
|
||||
|
||||
/** Re-run the whole use case after the supplied backoff, with an explicit reason. */
|
||||
public static RetryDecision retry(Duration delay, String reason) {
|
||||
return new RetryDecision(RetryDisposition.RETRY_FULL_TRANSACTION, delay, reason);
|
||||
}
|
||||
|
||||
/** Hand the failure to reconciliation; the commit outcome is not known. */
|
||||
public static RetryDecision reconcile(String reason) {
|
||||
return new RetryDecision(RetryDisposition.RECONCILE, Duration.ZERO, reason);
|
||||
}
|
||||
|
||||
/** Surface the failure; re-running it cannot help. */
|
||||
public static RetryDecision fail(String reason) {
|
||||
return new RetryDecision(RetryDisposition.FAIL, Duration.ZERO, reason);
|
||||
}
|
||||
|
||||
/** Whether this decision asks for another full-transaction attempt. */
|
||||
public boolean retrying() {
|
||||
return disposition == RetryDisposition.RETRY_FULL_TRANSACTION;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
/**
|
||||
* What the platform may do about a failed attempt (design §9.4).
|
||||
*
|
||||
* <p>{@link #RECONCILE} exists so that "we do not know" is a first-class outcome rather than a
|
||||
* retry in disguise.
|
||||
*/
|
||||
public enum RetryDisposition {
|
||||
|
||||
/** Re-run the entire use case in a new transaction and a new Persistence Context. */
|
||||
RETRY_FULL_TRANSACTION,
|
||||
|
||||
/** Hand the failure to domain-specific reconciliation; never re-run it automatically. */
|
||||
RECONCILE,
|
||||
|
||||
/** Surface the failure to the caller. */
|
||||
FAIL
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory;
|
||||
import java.time.Duration;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A named, bounded retry budget (design §19.3).
|
||||
*
|
||||
* <p>The profile is a value, not a strategy object: it says how many attempts are allowed, how the
|
||||
* backoff grows, how it is jittered, and which failure categories are eligible at all. The decision
|
||||
* to use it belongs to {@link JpaRetryPolicy}.
|
||||
*
|
||||
* <p>One category can never appear in {@code retryableFailures}: {@link
|
||||
* FailureCategory#COMPLETION_UNKNOWN}. A profile that listed it would let configuration re-run work
|
||||
* that may already be committed, so the constructor rejects it outright rather than trusting review
|
||||
* to catch it.
|
||||
*/
|
||||
public record RetryProfile(
|
||||
String name,
|
||||
int maxAttempts,
|
||||
Duration initialBackoff,
|
||||
Duration maxBackoff,
|
||||
double multiplier,
|
||||
JitterMode jitter,
|
||||
Set<FailureCategory> retryableFailures) {
|
||||
|
||||
/** Profile names are bounded because they become metric tags. */
|
||||
private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9.-]{2,63}");
|
||||
|
||||
/** The categories a profile is allowed to opt into (design §19.1). */
|
||||
private static final Set<FailureCategory> ELIGIBLE =
|
||||
Set.of(
|
||||
FailureCategory.SERIALIZATION_FAILURE,
|
||||
FailureCategory.DEADLOCK,
|
||||
FailureCategory.OPTIMISTIC_CONFLICT,
|
||||
FailureCategory.LOCK_NOT_AVAILABLE,
|
||||
FailureCategory.CONNECTION_UNAVAILABLE);
|
||||
|
||||
public RetryProfile {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(initialBackoff, "initialBackoff");
|
||||
Objects.requireNonNull(maxBackoff, "maxBackoff");
|
||||
Objects.requireNonNull(jitter, "jitter");
|
||||
Objects.requireNonNull(retryableFailures, "retryableFailures");
|
||||
if (!NAME.matcher(name).matches()) {
|
||||
throw new IllegalArgumentException("invalid retry profile name");
|
||||
}
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("maxAttempts must be at least 1");
|
||||
}
|
||||
if (initialBackoff.isNegative() || maxBackoff.isNegative()) {
|
||||
throw new IllegalArgumentException("retry backoff must not be negative");
|
||||
}
|
||||
if (maxBackoff.compareTo(initialBackoff) < 0) {
|
||||
throw new IllegalArgumentException("maxBackoff must not be smaller than initialBackoff");
|
||||
}
|
||||
if (!Double.isFinite(multiplier) || multiplier < 1.0d) {
|
||||
throw new IllegalArgumentException("retry multiplier must be finite and at least 1.0");
|
||||
}
|
||||
if (retryableFailures.contains(FailureCategory.COMPLETION_UNKNOWN)) {
|
||||
throw new IllegalArgumentException(
|
||||
"completion unknown is never a retryable failure category");
|
||||
}
|
||||
for (FailureCategory category : retryableFailures) {
|
||||
if (!ELIGIBLE.contains(category)) {
|
||||
throw new IllegalArgumentException("failure category is not retry eligible: " + category);
|
||||
}
|
||||
}
|
||||
retryableFailures =
|
||||
retryableFailures.isEmpty() ? Set.of() : Set.copyOf(EnumSet.copyOf(retryableFailures));
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile that never retries.
|
||||
*
|
||||
* <p>This is the default for any transaction profile that has not opted in, so an operation is
|
||||
* only re-run when someone decided it may be.
|
||||
*/
|
||||
public static RetryProfile none() {
|
||||
return new RetryProfile(
|
||||
"none", 1, Duration.ZERO, Duration.ZERO, 1.0d, JitterMode.NONE, Set.of());
|
||||
}
|
||||
|
||||
/** The Stable write default: bounded exponential backoff over the contention categories. */
|
||||
public static RetryProfile boundedContention(String name, int maxAttempts) {
|
||||
return new RetryProfile(
|
||||
name,
|
||||
maxAttempts,
|
||||
Duration.ofMillis(20),
|
||||
Duration.ofMillis(500),
|
||||
2.0d,
|
||||
JitterMode.FULL,
|
||||
Set.of(
|
||||
FailureCategory.SERIALIZATION_FAILURE,
|
||||
FailureCategory.DEADLOCK,
|
||||
FailureCategory.OPTIMISTIC_CONFLICT));
|
||||
}
|
||||
|
||||
/** Whether this profile permits more than the first attempt. */
|
||||
public boolean enabled() {
|
||||
return maxAttempts > 1 && !retryableFailures.isEmpty();
|
||||
}
|
||||
|
||||
/** Whether the supplied category is eligible under this profile. */
|
||||
public boolean allows(FailureCategory category) {
|
||||
return retryableFailures.contains(category);
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One physical attempt at a logical use case (design §19.2).
|
||||
*
|
||||
* <p>{@code number} is 1-based: the first execution is attempt 1, not a retry. {@code startedAt} is
|
||||
* the instant that attempt began, which is what lets the retry coordinator enforce an overall
|
||||
* deadline rather than only an attempt count.
|
||||
*/
|
||||
public record TransactionAttempt(int number, Instant startedAt) {
|
||||
|
||||
public TransactionAttempt {
|
||||
Objects.requireNonNull(startedAt, "startedAt");
|
||||
if (number < 1) {
|
||||
throw new IllegalArgumentException("transaction attempt number must be at least 1");
|
||||
}
|
||||
}
|
||||
|
||||
/** The first attempt at a use case. */
|
||||
public static TransactionAttempt first(Instant startedAt) {
|
||||
return new TransactionAttempt(1, startedAt);
|
||||
}
|
||||
|
||||
/** The attempt that follows this one, beginning at the supplied instant. */
|
||||
public TransactionAttempt next(Instant instant) {
|
||||
return new TransactionAttempt(number + 1, instant);
|
||||
}
|
||||
|
||||
/** How long this attempt has been running as of {@code now}. */
|
||||
public Duration elapsedAt(Instant now) {
|
||||
Duration elapsed = Duration.between(startedAt, now);
|
||||
return elapsed.isNegative() ? Duration.ZERO : elapsed;
|
||||
}
|
||||
|
||||
/** Whether this is the first execution rather than a retry. */
|
||||
public boolean isFirst() {
|
||||
return number == 1;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
/**
|
||||
* What the platform can prove about how far a transaction got (design §17.1).
|
||||
*
|
||||
* <p>This is evidence, not a guess. {@link #UNKNOWN} is a real, reportable state: it means the
|
||||
* driver could neither confirm the commit nor confirm the rollback, and the platform refuses to
|
||||
* collapse that into either. Only a failure observed while the phase is {@link #COMMITTING} may
|
||||
* become {@link
|
||||
* dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException}.
|
||||
*
|
||||
* <p>The enum lives in the framework-free core rather than in the Spring transaction adapter
|
||||
* because the design types the exception's evidence field, and the core error contract may not
|
||||
* depend on the adapter. See {@code docs/jpa/repository-adaptation.md} §4.
|
||||
*/
|
||||
public enum TransactionCompletionEvidence {
|
||||
|
||||
/** No transaction was begun for this unit of work. */
|
||||
NOT_STARTED,
|
||||
|
||||
/** A transaction is open and statements are executing. */
|
||||
ACTIVE,
|
||||
|
||||
/** The commit has been handed to the provider and no result has come back yet. */
|
||||
COMMITTING,
|
||||
|
||||
/** The provider confirmed the commit. */
|
||||
COMMITTED,
|
||||
|
||||
/** The provider confirmed the rollback. */
|
||||
ROLLED_BACK,
|
||||
|
||||
/** The commit outcome could not be determined; reconciliation owns the resolution. */
|
||||
UNKNOWN
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.api.transaction;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A named, immutable description of one transaction boundary (design §9.2).
|
||||
*
|
||||
* <p>A write profile must carry a positive, finite timeout. An unbounded write transaction is how a
|
||||
* single stuck statement holds a connection, a lock, and a row version indefinitely, so the type
|
||||
* refuses to represent one. Read profiles may leave the timeout at zero, meaning "the connection
|
||||
* default applies".
|
||||
*/
|
||||
public record TransactionProfile(
|
||||
String name,
|
||||
PropagationMode propagation,
|
||||
IsolationLevel isolation,
|
||||
Duration timeout,
|
||||
boolean readOnly,
|
||||
RetryProfile retryProfile) {
|
||||
|
||||
/** Profile names are bounded because they become metric tags. */
|
||||
private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9.-]{2,63}");
|
||||
|
||||
public TransactionProfile {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(propagation, "propagation");
|
||||
Objects.requireNonNull(isolation, "isolation");
|
||||
Objects.requireNonNull(retryProfile, "retryProfile");
|
||||
if (!NAME.matcher(name).matches()) {
|
||||
throw new IllegalArgumentException("invalid transaction profile name");
|
||||
}
|
||||
if (!readOnly && (timeout == null || timeout.isZero() || timeout.isNegative())) {
|
||||
throw new IllegalArgumentException("write transaction requires positive timeout");
|
||||
}
|
||||
if (timeout == null) {
|
||||
timeout = Duration.ZERO;
|
||||
}
|
||||
if (timeout.isNegative()) {
|
||||
throw new IllegalArgumentException("transaction timeout must not be negative");
|
||||
}
|
||||
if (readOnly && retryProfile.enabled() && propagation == PropagationMode.REQUIRES_NEW) {
|
||||
throw new IllegalArgumentException(
|
||||
"a read-only REQUIRES_NEW profile must not carry a retry budget");
|
||||
}
|
||||
}
|
||||
|
||||
/** The Stable write default: {@code REQUIRED + READ_COMMITTED} with no retry budget. */
|
||||
public static TransactionProfile write(String name, Duration timeout) {
|
||||
return new TransactionProfile(
|
||||
name,
|
||||
PropagationMode.REQUIRED,
|
||||
IsolationLevel.READ_COMMITTED,
|
||||
timeout,
|
||||
false,
|
||||
RetryProfile.none());
|
||||
}
|
||||
|
||||
/** The Stable read default: {@code REQUIRED + READ_COMMITTED}, read-only, no retry budget. */
|
||||
public static TransactionProfile read(String name, Duration timeout) {
|
||||
return new TransactionProfile(
|
||||
name,
|
||||
PropagationMode.REQUIRED,
|
||||
IsolationLevel.READ_COMMITTED,
|
||||
timeout,
|
||||
true,
|
||||
RetryProfile.none());
|
||||
}
|
||||
|
||||
/** Returns a copy of this profile carrying the supplied retry budget. */
|
||||
public TransactionProfile withRetryProfile(RetryProfile profile) {
|
||||
return new TransactionProfile(name, propagation, isolation, timeout, readOnly, profile);
|
||||
}
|
||||
|
||||
/** Whether the timeout is a real bound rather than "use the connection default". */
|
||||
public boolean hasTimeout() {
|
||||
return !timeout.isZero();
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.auditing;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import org.springframework.data.annotation.CreatedBy;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedBy;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
|
||||
/**
|
||||
* Opt-in technical audit columns (design §10.2).
|
||||
*
|
||||
* <p>An {@code @Embeddable} rather than a mandatory {@code BaseEntity}. A platform-wide base class
|
||||
* forces four columns onto every table including the ones where they are meaningless — join tables,
|
||||
* immutable event records, outbox rows — and, worse, it puts the platform in the entity hierarchy,
|
||||
* so a later platform change reshapes every domain aggregate.
|
||||
*
|
||||
* <p>This is <em>technical</em> auditing: who last touched the row, mechanically. It is not
|
||||
* business audit and not entity history. Business audit answers "what did the user do and why" and
|
||||
* belongs to the domain; entity history answers "what did this row look like at revision N" and
|
||||
* belongs to Envers (design §35). Conflating them produces an audit trail that satisfies neither
|
||||
* requirement.
|
||||
*
|
||||
* <p>{@code createdAt}/{@code createdBy} are {@code updatable = false}: a create stamp that a later
|
||||
* update can rewrite is not a create stamp.
|
||||
*/
|
||||
@Embeddable
|
||||
public class AuditMetadata {
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@CreatedBy
|
||||
@Column(name = "created_by", updatable = false, length = 64)
|
||||
private String createdBy;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "modified_at")
|
||||
private Instant modifiedAt;
|
||||
|
||||
@LastModifiedBy
|
||||
@Column(name = "modified_by", length = 64)
|
||||
private String modifiedBy;
|
||||
|
||||
protected AuditMetadata() {
|
||||
// required by the persistence provider
|
||||
}
|
||||
|
||||
/** When the row was first written. */
|
||||
public Instant createdAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/** The bounded actor identity that first wrote the row. */
|
||||
public String createdBy() {
|
||||
return createdBy;
|
||||
}
|
||||
|
||||
/** When the row was last modified. */
|
||||
public Instant modifiedAt() {
|
||||
return modifiedAt;
|
||||
}
|
||||
|
||||
/** The bounded actor identity that last modified the row. */
|
||||
public String modifiedBy() {
|
||||
return modifiedBy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof AuditMetadata audit)) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(createdAt, audit.createdAt)
|
||||
&& Objects.equals(createdBy, audit.createdBy)
|
||||
&& Objects.equals(modifiedAt, audit.modifiedAt)
|
||||
&& Objects.equals(modifiedBy, audit.modifiedBy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(createdAt, createdBy, modifiedAt, modifiedBy);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.auditing;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.auditing.DateTimeProvider;
|
||||
|
||||
/**
|
||||
* The wiring an application must supply to switch technical auditing on (design §10.2).
|
||||
*
|
||||
* <p>Deliberately not a Spring {@code @Configuration}. Auditing is opt-in, and an adapter leaf that
|
||||
* auto-enabled it would stamp audit columns on every entity in every application that merely has
|
||||
* this module on the classpath — including the ones whose tables have no such columns, where the
|
||||
* result is a startup failure rather than a feature.
|
||||
*
|
||||
* <p>The composition root builds these two beans and enables auditing itself, which keeps the
|
||||
* decision where {@code AGENTS.md} puts composition.
|
||||
*/
|
||||
public final class JpaAuditingConfiguration {
|
||||
|
||||
private final Clock clock;
|
||||
private final JpaAuditorProvider auditorProvider;
|
||||
|
||||
public JpaAuditingConfiguration(Clock clock, JpaAuditorProvider auditorProvider) {
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.auditorProvider = Objects.requireNonNull(auditorProvider, "auditorProvider");
|
||||
}
|
||||
|
||||
/**
|
||||
* The time source audit stamps come from.
|
||||
*
|
||||
* <p>It wraps the injected {@link Clock} rather than reading {@code Instant.now()} so that a test
|
||||
* can fix the clock and assert the stamp, which is otherwise untestable.
|
||||
*/
|
||||
public DateTimeProvider dateTimeProvider() {
|
||||
return () -> Optional.of(clock.instant());
|
||||
}
|
||||
|
||||
/** The actor source audit stamps come from. */
|
||||
public JpaAuditorProvider auditorProvider() {
|
||||
return auditorProvider;
|
||||
}
|
||||
|
||||
/** The clock audit stamps are read from. */
|
||||
public Clock clock() {
|
||||
return clock;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.auditing;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.data.domain.AuditorAware;
|
||||
|
||||
/**
|
||||
* Supplies the bounded actor identity Spring Data stamps onto audited rows (design §10.2).
|
||||
*
|
||||
* <p>The identity is opaque and bounded — a user id or a system actor name, never a display name,
|
||||
* an email, or a principal object. Those are PII, and an audit column is copied into every backup,
|
||||
* every replica, and every export of that table.
|
||||
*
|
||||
* <p>Background work gets an explicit system actor rather than an empty column. "Who changed this"
|
||||
* answered by a null is indistinguishable from a bug in the auditing setup.
|
||||
*/
|
||||
public final class JpaAuditorProvider implements AuditorAware<String> {
|
||||
|
||||
/** The actor recorded for scheduled jobs, migrations, and other unattended work. */
|
||||
public static final String SYSTEM_ACTOR = "system";
|
||||
|
||||
private static final Pattern ACTOR = Pattern.compile("[A-Za-z0-9._:-]{1,64}");
|
||||
|
||||
private final Supplier<Optional<String>> currentActor;
|
||||
|
||||
public JpaAuditorProvider(Supplier<Optional<String>> currentActor) {
|
||||
this.currentActor = Objects.requireNonNull(currentActor, "currentActor");
|
||||
}
|
||||
|
||||
/** A provider that always records the system actor. */
|
||||
public static JpaAuditorProvider system() {
|
||||
return new JpaAuditorProvider(() -> Optional.of(SYSTEM_ACTOR));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getCurrentAuditor() {
|
||||
return currentActor.get().map(JpaAuditorProvider::bound).or(() -> Optional.of(SYSTEM_ACTOR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds an actor identity.
|
||||
*
|
||||
* <p>An identity that is not already bounded is replaced rather than truncated: truncating an
|
||||
* email still leaves most of it in the column.
|
||||
*/
|
||||
private static String bound(String actor) {
|
||||
return actor != null && ACTOR.matcher(actor).matches() ? actor : SYSTEM_ACTOR;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.cache;
|
||||
|
||||
/**
|
||||
* How a cached region reconciles concurrent writes (design §34).
|
||||
*
|
||||
* <p>Choosing one is a correctness decision, not a tuning knob. {@code READ_ONLY} on a mutable
|
||||
* entity throws at the first update; {@code NONSTRICT_READ_WRITE} tolerates a stale window that
|
||||
* some domains cannot; {@code READ_WRITE} adds soft-lock bookkeeping. Requiring the choice per
|
||||
* region is what stops a default from being applied to an entity it is wrong for.
|
||||
*/
|
||||
public enum CacheConcurrencyStrategy {
|
||||
|
||||
/** Immutable reference data; any update to a cached instance is an error. */
|
||||
READ_ONLY,
|
||||
|
||||
/** Mutable data that tolerates a brief stale window after a write. */
|
||||
NONSTRICT_READ_WRITE,
|
||||
|
||||
/** Mutable data that requires soft locking around writes. */
|
||||
READ_WRITE,
|
||||
|
||||
/** Only correct behind a JTA transaction manager. */
|
||||
TRANSACTIONAL
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.cache;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The entities enrolled in the second-level cache, and how each is cached (design §34).
|
||||
*
|
||||
* <p>Enrolment is per entity and explicit. A blanket "cache everything" setting caches the entities
|
||||
* whose staleness matters most alongside the reference data it was meant for, and the failure is
|
||||
* invisible: reads succeed, they are just answering from a version of the row that no longer
|
||||
* exists.
|
||||
*/
|
||||
public final class CacheRegionCatalog {
|
||||
|
||||
private final Map<String, CacheConcurrencyStrategy> byEntityName;
|
||||
|
||||
public CacheRegionCatalog(Map<String, CacheConcurrencyStrategy> regions) {
|
||||
Objects.requireNonNull(regions, "regions");
|
||||
Map<String, CacheConcurrencyStrategy> copy = new LinkedHashMap<>();
|
||||
regions.forEach(
|
||||
(entity, strategy) -> {
|
||||
Objects.requireNonNull(entity, "entity name");
|
||||
Objects.requireNonNull(strategy, "concurrency strategy");
|
||||
if (entity.isBlank()) {
|
||||
throw new IllegalArgumentException("cached entity name must not be blank");
|
||||
}
|
||||
copy.put(entity, strategy);
|
||||
});
|
||||
this.byEntityName = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
/** A catalog with nothing enrolled. */
|
||||
public static CacheRegionCatalog empty() {
|
||||
return new CacheRegionCatalog(Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when a cacheable entity is not enrolled here.
|
||||
*
|
||||
* @param cacheableEntities the entity names the provider reports as cacheable
|
||||
*/
|
||||
public void validate(Set<String> cacheableEntities) {
|
||||
Objects.requireNonNull(cacheableEntities, "cacheableEntities");
|
||||
for (String entity : cacheableEntities) {
|
||||
if (!byEntityName.containsKey(entity)) {
|
||||
throw new IllegalStateException(
|
||||
"entity '"
|
||||
+ entity
|
||||
+ "' is cacheable but not enrolled in the cache region catalog;"
|
||||
+ " enrol it with an explicit concurrency strategy or remove @Cacheable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The concurrency strategy registered for an entity, when it is enrolled. */
|
||||
public java.util.Optional<CacheConcurrencyStrategy> strategyFor(String entityName) {
|
||||
return java.util.Optional.ofNullable(byEntityName.get(entityName));
|
||||
}
|
||||
|
||||
/** The enrolled entity names. */
|
||||
public Set<String> enrolledEntities() {
|
||||
return byEntityName.keySet();
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.cache;
|
||||
|
||||
import jakarta.persistence.SharedCacheMode;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Refuses a second-level cache configuration that is unsafe by default (design §34).
|
||||
*
|
||||
* <p>Two rules, both about defaults that look harmless.
|
||||
*
|
||||
* <p><b>Query Cache stays off.</b> It caches result-id lists keyed by query and parameters, and
|
||||
* those entries are invalidated by table-space timestamps — so any write to a table a cached query
|
||||
* touches invalidates every cached query over it. On a write-active table it costs more than it
|
||||
* saves, and it does so silently.
|
||||
*
|
||||
* <p><b>{@code ENABLE_SELECTIVE} only.</b> {@code ALL} caches every entity, including the ones
|
||||
* whose staleness is a correctness problem rather than a performance one. Selective enrolment is
|
||||
* what makes each entity's cacheability a decision someone made.
|
||||
*/
|
||||
public final class HibernateCacheGuard {
|
||||
|
||||
/**
|
||||
* Validates the cache configuration against the enrolled regions.
|
||||
*
|
||||
* @throws IllegalStateException when the configuration is not one the design permits
|
||||
*/
|
||||
public void validate(HibernateCacheSettings settings, CacheRegionCatalog catalog) {
|
||||
Objects.requireNonNull(settings, "settings");
|
||||
Objects.requireNonNull(catalog, "catalog");
|
||||
if (!settings.secondLevelCacheEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (settings.queryCacheEnabled()) {
|
||||
throw new IllegalStateException(
|
||||
"Query Cache is disabled by default: its entries are invalidated by any write to the"
|
||||
+ " tables the query touches, so on a write-active table it costs more than it saves."
|
||||
+ " Enabling it requires an explicit experimental approval.");
|
||||
}
|
||||
if (settings.sharedCacheMode() != SharedCacheMode.ENABLE_SELECTIVE) {
|
||||
throw new IllegalStateException(
|
||||
"Use ENABLE_SELECTIVE for L2 cache; "
|
||||
+ settings.sharedCacheMode()
|
||||
+ " caches entities whose staleness is a correctness problem");
|
||||
}
|
||||
catalog.validate(settings.cacheableEntities());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when a cached entity has no bulk-eviction strategy.
|
||||
*
|
||||
* <p>Bulk DML bypasses the second-level cache entirely, so a cached entity updated in bulk keeps
|
||||
* serving pre-update values until its region expires. Declaring the eviction is the only thing
|
||||
* that closes that window.
|
||||
*/
|
||||
public void requireBulkEviction(CacheRegionCatalog catalog, java.util.Set<String> bulkEntities) {
|
||||
Objects.requireNonNull(catalog, "catalog");
|
||||
Objects.requireNonNull(bulkEntities, "bulkEntities");
|
||||
for (String entity : bulkEntities) {
|
||||
if (catalog.strategyFor(entity).isPresent()) {
|
||||
throw new IllegalStateException(
|
||||
"entity '"
|
||||
+ entity
|
||||
+ "' is second-level cached and targeted by bulk DML; bulk"
|
||||
+ " statements bypass the cache, so the region must be evicted explicitly after"
|
||||
+ " the statement or the entity must not be cached");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.cache;
|
||||
|
||||
import jakarta.persistence.SharedCacheMode;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The declared second-level cache policy of an application (design §34).
|
||||
*
|
||||
* <p>The policy also records the two operational assumptions that decide whether the cache is
|
||||
* correct at all, because both are invisible in configuration and expensive to discover in
|
||||
* production:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>External writers.</b> A second-level cache is only coherent if this application is the
|
||||
* sole writer. A batch job, an admin console, or a replication stream writing the same tables
|
||||
* makes cached entities silently stale, and no cache setting can detect it.
|
||||
* <li><b>Cluster invalidation.</b> With more than one instance and a local cache, an eviction on
|
||||
* one node does not reach the others. Without a distributed region, every extra instance adds
|
||||
* another independent stale copy.
|
||||
* </ul>
|
||||
*/
|
||||
public record HibernateCachePolicy(
|
||||
boolean soleWriter,
|
||||
boolean clusterInvalidationConfigured,
|
||||
Map<String, CacheConcurrencyStrategy> regions) {
|
||||
|
||||
public HibernateCachePolicy {
|
||||
regions = Map.copyOf(Objects.requireNonNull(regions, "regions"));
|
||||
}
|
||||
|
||||
/** The catalog this policy describes. */
|
||||
public CacheRegionCatalog catalog() {
|
||||
return new CacheRegionCatalog(regions);
|
||||
}
|
||||
|
||||
/** The shared cache mode this policy requires. */
|
||||
public SharedCacheMode sharedCacheMode() {
|
||||
return SharedCacheMode.ENABLE_SELECTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the policy may be enabled for a multi-instance deployment.
|
||||
*
|
||||
* <p>A single instance that is the sole writer is coherent on its own; anything else needs
|
||||
* cluster invalidation configured before the cache can be trusted.
|
||||
*/
|
||||
public boolean safeForMultipleInstances() {
|
||||
return soleWriter && clusterInvalidationConfigured;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.cache;
|
||||
|
||||
import jakarta.persistence.SharedCacheMode;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The second-level cache configuration as the platform reads it back (design §34).
|
||||
*
|
||||
* @param cacheableEntities the entity names the provider reports as cacheable
|
||||
*/
|
||||
public record HibernateCacheSettings(
|
||||
boolean secondLevelCacheEnabled,
|
||||
boolean queryCacheEnabled,
|
||||
SharedCacheMode sharedCacheMode,
|
||||
Set<String> cacheableEntities) {
|
||||
|
||||
public HibernateCacheSettings {
|
||||
Objects.requireNonNull(sharedCacheMode, "sharedCacheMode");
|
||||
cacheableEntities = Set.copyOf(Objects.requireNonNull(cacheableEntities, "cacheableEntities"));
|
||||
}
|
||||
|
||||
/** The configuration of a runtime with no second-level cache at all. */
|
||||
public static HibernateCacheSettings disabled() {
|
||||
return new HibernateCacheSettings(false, false, SharedCacheMode.NONE, Set.of());
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -12,9 +12,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
*
|
||||
* <p>Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the
|
||||
* two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first
|
||||
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming
|
||||
* {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that
|
||||
* caused it.
|
||||
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming {@code
|
||||
* OutboxClaimRepository} — a symptom several layers away from the misspelled value that caused it.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX)
|
||||
public record PersistenceVendorSettings(Vendor vendor) {
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.envers;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One historical version of an entity (design §35).
|
||||
*
|
||||
* @param <T> the audited entity type
|
||||
*/
|
||||
public record EntityRevision<T>(long revisionNumber, T entity, EnversRevisionMetadata metadata) {
|
||||
|
||||
public EntityRevision {
|
||||
Objects.requireNonNull(metadata, "metadata");
|
||||
if (revisionNumber < 1L) {
|
||||
throw new IllegalArgumentException("revision number must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.envers;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Refuses an entity-history configuration that would audit more than someone chose to (design §35).
|
||||
*
|
||||
* <p>Two failures this guard exists to prevent.
|
||||
*
|
||||
* <p><b>Blanket enrolment.</b> Putting {@code @Audited} on a shared base class enrols every entity
|
||||
* that extends it. Envers then writes a full copy of every row version to an audit table, which
|
||||
* multiplies write volume and storage across entities nobody decided to audit.
|
||||
*
|
||||
* <p><b>Production without a retention and PII policy.</b> An audit table keeps every previous
|
||||
* value forever by default, including the personal data a later correction or erasure removed from
|
||||
* the live row. That is a data-protection problem that only gets more expensive the longer it runs.
|
||||
*/
|
||||
public final class EnversConfigurationGuard {
|
||||
|
||||
/**
|
||||
* Validates the enrolled entities against the declared policy.
|
||||
*
|
||||
* @param auditedEntities the entity names the provider reports as audited
|
||||
* @throws IllegalStateException when an entity is audited without being enrolled
|
||||
*/
|
||||
public void validate(EnversHistoryPolicy policy, Set<String> auditedEntities) {
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
Objects.requireNonNull(auditedEntities, "auditedEntities");
|
||||
for (String entity : auditedEntities) {
|
||||
if (!policy.audits(entity)) {
|
||||
throw new IllegalStateException(
|
||||
"entity '"
|
||||
+ entity
|
||||
+ "' is @Audited but not enrolled in the history policy; enrol it"
|
||||
+ " deliberately or remove the annotation — a shared @Audited base class enrols"
|
||||
+ " every subclass");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when history is enabled in production without a retention and PII policy.
|
||||
*
|
||||
* @throws IllegalStateException when the policy is not production ready
|
||||
*/
|
||||
public void requireProductionReady(EnversHistoryPolicy policy) {
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
if (!policy.productionReady()) {
|
||||
throw new IllegalStateException(
|
||||
"entity history requires a declared retention period and PII deletion policy before it"
|
||||
+ " may be enabled in production: audit tables retain every previous value,"
|
||||
+ " including data an erasure request removed from the live row");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the Envers module is on the runtime classpath at all. */
|
||||
public boolean enversAvailable() {
|
||||
try {
|
||||
Class.forName("org.hibernate.envers.AuditReaderFactory");
|
||||
return true;
|
||||
} catch (ClassNotFoundException absent) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.envers;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which entities keep history, and under what retention (design §35).
|
||||
*
|
||||
* <p>Retention is mandatory, and it is a data-protection requirement rather than a housekeeping
|
||||
* one. An audit table accumulates every previous value of every audited row, so a column holding
|
||||
* personal data keeps holding it after the live row is corrected or erased — which is precisely the
|
||||
* case a deletion request is about. Declaring retention and a PII policy before production is how
|
||||
* that stays a decision instead of a discovery.
|
||||
*/
|
||||
public record EnversHistoryPolicy(
|
||||
Set<String> auditedEntities, Duration retention, boolean piiPolicyDeclared) {
|
||||
|
||||
public EnversHistoryPolicy {
|
||||
auditedEntities = Set.copyOf(Objects.requireNonNull(auditedEntities, "auditedEntities"));
|
||||
Objects.requireNonNull(retention, "retention");
|
||||
if (retention.isZero() || retention.isNegative()) {
|
||||
throw new IllegalArgumentException("entity history requires a positive retention period");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an entity is enrolled for history. */
|
||||
public boolean audits(String entityName) {
|
||||
return auditedEntities.contains(entityName);
|
||||
}
|
||||
|
||||
/** Whether this policy may be enabled in a production profile. */
|
||||
public boolean productionReady() {
|
||||
return piiPolicyDeclared && !auditedEntities.isEmpty();
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.envers;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Reads the recorded revisions of an audited entity (design §35).
|
||||
*
|
||||
* <p>Read-only by construction. History is append-only: it is written by the revision listener as a
|
||||
* side effect of ordinary writes, and an API that could modify it would make the record something
|
||||
* an application bug can rewrite.
|
||||
*/
|
||||
public interface EnversHistoryReader {
|
||||
|
||||
/**
|
||||
* The revisions recorded for one entity instance, oldest first.
|
||||
*
|
||||
* <p>An entity that is not enrolled for history returns an empty list rather than throwing —
|
||||
* "this entity has no history" is a legitimate answer, and the enrolment check belongs to {@link
|
||||
* EnversConfigurationGuard} at startup.
|
||||
*/
|
||||
<T> List<EntityRevision<T>> revisions(Class<T> entityType, Object id);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.envers;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The bounded metadata recorded alongside one revision (design §35).
|
||||
*
|
||||
* <p>An actor id and a correlation id, and nothing else. Storing the security principal — as
|
||||
* opposed to an opaque reference to it — copies roles, tokens, and personal attributes into an
|
||||
* append-only table that outlives the session they came from.
|
||||
*/
|
||||
public record EnversRevisionMetadata(String actor, String correlationId, Instant recordedAt) {
|
||||
|
||||
private static final Pattern BOUNDED = Pattern.compile("[A-Za-z0-9._:-]{1,64}");
|
||||
|
||||
public EnversRevisionMetadata {
|
||||
Objects.requireNonNull(recordedAt, "recordedAt");
|
||||
actor = bound(actor);
|
||||
correlationId = bound(correlationId);
|
||||
}
|
||||
|
||||
private static String bound(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return BOUNDED.matcher(candidate).matches() ? candidate : "redacted";
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.envers;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.hibernate.envers.AuditReader;
|
||||
import org.hibernate.envers.AuditReaderFactory;
|
||||
import org.hibernate.envers.query.AuditEntity;
|
||||
|
||||
/**
|
||||
* Reads recorded revisions through Hibernate Envers (design §35).
|
||||
*
|
||||
* <p>Only enrolled entities are read. Asking Envers for the history of an entity that was never
|
||||
* audited throws a provider exception, which would surface to a caller as an internal error rather
|
||||
* than as the true answer — that this entity keeps no history.
|
||||
*
|
||||
* <p>Envers is {@code compileOnly} for this leaf, so a deployment that has not opted in never loads
|
||||
* this class. The guard reports its absence at startup rather than letting the first history read
|
||||
* fail with a missing class.
|
||||
*/
|
||||
public final class HibernateEnversHistoryReader implements EnversHistoryReader {
|
||||
|
||||
private final EntityManager entityManager;
|
||||
private final EnversHistoryPolicy policy;
|
||||
|
||||
public HibernateEnversHistoryReader(EntityManager entityManager, EnversHistoryPolicy policy) {
|
||||
this.entityManager = Objects.requireNonNull(entityManager, "entityManager");
|
||||
this.policy = Objects.requireNonNull(policy, "policy");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> List<EntityRevision<T>> revisions(Class<T> entityType, Object id) {
|
||||
Objects.requireNonNull(entityType, "entityType");
|
||||
Objects.requireNonNull(id, "id");
|
||||
if (!policy.audits(entityType.getSimpleName())) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
AuditReader reader = AuditReaderFactory.get(entityManager);
|
||||
List<Number> revisionNumbers = reader.getRevisions(entityType, id);
|
||||
List<EntityRevision<T>> revisions = new ArrayList<>(revisionNumbers.size());
|
||||
for (Number revisionNumber : revisionNumbers) {
|
||||
T entity = reader.find(entityType, id, revisionNumber);
|
||||
Instant recordedAt = reader.getRevisionDate(revisionNumber).toInstant();
|
||||
revisions.add(
|
||||
new EntityRevision<>(
|
||||
revisionNumber.longValue(),
|
||||
entity,
|
||||
new EnversRevisionMetadata(null, null, recordedAt)));
|
||||
}
|
||||
return List.copyOf(revisions);
|
||||
}
|
||||
|
||||
/** The revision numbers recorded for one entity instance, oldest first. */
|
||||
public List<Number> revisionNumbers(Class<?> entityType, Object id) {
|
||||
Objects.requireNonNull(entityType, "entityType");
|
||||
Objects.requireNonNull(id, "id");
|
||||
if (!policy.audits(entityType.getSimpleName())) {
|
||||
return List.of();
|
||||
}
|
||||
return List.copyOf(AuditReaderFactory.get(entityManager).getRevisions(entityType, id));
|
||||
}
|
||||
|
||||
/** Whether any revision at all was recorded for one entity instance. */
|
||||
public boolean hasHistory(Class<?> entityType, Object id) {
|
||||
return !revisionNumbers(entityType, id).isEmpty();
|
||||
}
|
||||
|
||||
/** The Envers query property used to filter a revision query by entity id. */
|
||||
public static Object idProperty() {
|
||||
return AuditEntity.id();
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental;
|
||||
|
||||
/**
|
||||
* The experimental capabilities and the flag each one requires (experimental plan §Global
|
||||
* Constraints).
|
||||
*
|
||||
* <p>Every one of these is off unless its flag is explicitly true. The flag is not a convenience:
|
||||
* these features change tenant isolation, read consistency, or the provider version, and each is
|
||||
* only as safe as the evidence suite that has run against it.
|
||||
*/
|
||||
public enum ExperimentalFeature {
|
||||
|
||||
/** Shared-schema multi-tenancy with a tenant column. */
|
||||
MULTITENANCY_COLUMN("backend.jpa.experimental.multitenancy-column"),
|
||||
|
||||
/** PostgreSQL row-level-security tenant isolation. */
|
||||
MULTITENANCY_RLS("backend.jpa.experimental.multitenancy-rls"),
|
||||
|
||||
/** Schema-per-tenant isolation. */
|
||||
MULTITENANCY_SCHEMA("backend.jpa.experimental.multitenancy-schema"),
|
||||
|
||||
/** Database-per-tenant isolation. */
|
||||
MULTITENANCY_DATABASE("backend.jpa.experimental.multitenancy-database"),
|
||||
|
||||
/** Consistency-aware read replica routing. */
|
||||
READ_REPLICA("backend.jpa.experimental.read-replica"),
|
||||
|
||||
/** Jakarta Persistence 4.0 compatibility lane. */
|
||||
JAKARTA_PERSISTENCE_4("backend.jpa.experimental.jakarta-persistence-4"),
|
||||
|
||||
/** Hibernate ORM 8 compatibility lane. */
|
||||
HIBERNATE_8("backend.jpa.experimental.hibernate-8"),
|
||||
|
||||
/** PostgreSQL 19 compatibility lane. */
|
||||
POSTGRESQL_19("backend.jpa.experimental.postgresql-19");
|
||||
|
||||
private final String property;
|
||||
|
||||
ExperimentalFeature(String property) {
|
||||
this.property = property;
|
||||
}
|
||||
|
||||
/** The property that must be {@code true} for this feature to run. */
|
||||
public String property() {
|
||||
return property;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Fails closed when an experimental module is present but its flag is not set (experimental plan
|
||||
* Task 1).
|
||||
*
|
||||
* <p>Presence on the classpath is not consent. An experimental module can arrive transitively, and
|
||||
* a tenant-isolation or replica-routing feature that switched itself on because a jar was present
|
||||
* would be the worst possible default. The gate makes the absence of a decision an error rather
|
||||
* than an activation.
|
||||
*/
|
||||
public final class ExperimentalFeatureGate {
|
||||
|
||||
/**
|
||||
* Fails unless {@code feature} is explicitly enabled.
|
||||
*
|
||||
* @throws IllegalStateException naming the exact property that must be set
|
||||
*/
|
||||
public void requireEnabled(ExperimentalFeature feature, Map<String, Boolean> flags) {
|
||||
Objects.requireNonNull(feature, "feature");
|
||||
Objects.requireNonNull(flags, "flags");
|
||||
if (!Boolean.TRUE.equals(flags.get(feature.property()))) {
|
||||
throw new IllegalStateException(feature.property() + "=true is required");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a feature is explicitly enabled. */
|
||||
public boolean isEnabled(ExperimentalFeature feature, Map<String, Boolean> flags) {
|
||||
return Boolean.TRUE.equals(flags.get(feature.property()));
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.database;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Closes a tenant pool without letting one failure strand the rest (experimental plan Task 5).
|
||||
*
|
||||
* <p>Closing is best-effort by design. Eviction happens during tenant removal and credential
|
||||
* rotation, and a pool whose server is already unreachable will throw on close — propagating that
|
||||
* would abandon the remaining pools mid-loop, leaking exactly the connections the eviction was
|
||||
* meant to release.
|
||||
*/
|
||||
public final class TenantDataSourceLifecycle {
|
||||
|
||||
private TenantDataSourceLifecycle() {}
|
||||
|
||||
/** Closes a pool, swallowing a close failure so the caller can continue evicting. */
|
||||
public static void drainAndClose(AutoCloseable pool) {
|
||||
Objects.requireNonNull(pool, "pool");
|
||||
try {
|
||||
pool.close();
|
||||
} catch (Exception unreachable) {
|
||||
// A pool whose server is already gone throws on close; the connections are gone with it,
|
||||
// and the remaining tenants still need to be evicted.
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.database;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.ToIntFunction;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* Opens per-tenant pools lazily, inside a global budget (experimental plan Task 5).
|
||||
*
|
||||
* <p>Lazily, because a deployment with a thousand tenants must not open a thousand pools at startup
|
||||
* for the handful that are active. Inside a budget, because the alternative is an unbounded number
|
||||
* of pools whose combined connection count exceeds the server's {@code max_connections} — at which
|
||||
* point every tenant fails, not just the newest one.
|
||||
*
|
||||
* <p>Nothing here exposes a tenant's JDBC URL or credentials. A diagnostic that listed them would
|
||||
* publish one tenant's connection details to whoever can read the diagnostics.
|
||||
*/
|
||||
public final class TenantDataSourceRegistry implements AutoCloseable {
|
||||
|
||||
private final Map<TenantId, DataSource> pools = new LinkedHashMap<>();
|
||||
private final Function<TenantId, DataSource> poolFactory;
|
||||
private final ToIntFunction<DataSource> poolSize;
|
||||
private final TenantPoolBudget budget;
|
||||
|
||||
/**
|
||||
* @param poolFactory opens a pool for a tenant from its secret-backed connection profile
|
||||
* @param poolSize reports a pool's maximum connection count, for the global budget
|
||||
*/
|
||||
public TenantDataSourceRegistry(
|
||||
Function<TenantId, DataSource> poolFactory,
|
||||
ToIntFunction<DataSource> poolSize,
|
||||
TenantPoolBudget budget) {
|
||||
this.poolFactory = Objects.requireNonNull(poolFactory, "poolFactory");
|
||||
this.poolSize = Objects.requireNonNull(poolSize, "poolSize");
|
||||
this.budget = Objects.requireNonNull(budget, "budget");
|
||||
}
|
||||
|
||||
/**
|
||||
* The tenant's pool, opening it if the budget allows.
|
||||
*
|
||||
* @throws IllegalStateException when the global pool budget is exhausted
|
||||
*/
|
||||
public synchronized DataSource require(TenantId tenant) {
|
||||
Objects.requireNonNull(tenant, "tenant");
|
||||
DataSource existing = pools.get(tenant);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
budget.requireCapacity(pools.size(), allocatedConnections());
|
||||
DataSource opened = poolFactory.apply(tenant);
|
||||
pools.put(tenant, opened);
|
||||
return opened;
|
||||
}
|
||||
|
||||
/** Opens pools for {@code count} tenants; used by the capacity contract. */
|
||||
public synchronized void openTenants(int count) {
|
||||
for (int index = 0; index < count; index++) {
|
||||
require(new TenantId("tenant-" + index));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes and forgets a tenant's pool.
|
||||
*
|
||||
* <p>Used on tenant removal and on credential rotation — a rotated credential means the open pool
|
||||
* is holding connections authenticated with a secret that is no longer valid.
|
||||
*/
|
||||
public synchronized void evict(TenantId tenant) {
|
||||
DataSource removed = pools.remove(tenant);
|
||||
if (removed instanceof AutoCloseable closeable) {
|
||||
TenantDataSourceLifecycle.drainAndClose(closeable);
|
||||
}
|
||||
}
|
||||
|
||||
/** How many pools are currently open. */
|
||||
public synchronized int openPools() {
|
||||
return pools.size();
|
||||
}
|
||||
|
||||
/** How many connections all open pools may hold between them. */
|
||||
public synchronized int allocatedConnections() {
|
||||
return pools.values().stream().mapToInt(poolSize).sum();
|
||||
}
|
||||
|
||||
/** The tenants with an open pool. */
|
||||
public synchronized Set<TenantId> openTenantIds() {
|
||||
return Set.copyOf(pools.keySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
pools.values().stream()
|
||||
.filter(AutoCloseable.class::isInstance)
|
||||
.map(AutoCloseable.class::cast)
|
||||
.forEach(TenantDataSourceLifecycle::drainAndClose);
|
||||
pools.clear();
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.database;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* One {@code EntityManagerFactory} per tenant database (experimental plan Task 5).
|
||||
*
|
||||
* <p>Database-per-tenant needs a factory per tenant, not merely a data source per tenant: an {@code
|
||||
* EntityManagerFactory} owns the dialect, the schema validation result, and the second-level cache,
|
||||
* and none of those are shareable across databases that may be on different versions or at
|
||||
* different migration states.
|
||||
*
|
||||
* <p>That is also the cost. Each factory carries its own metamodel and caches, so the tenant
|
||||
* ceiling here is about memory as much as about connections.
|
||||
*/
|
||||
public final class TenantEntityManagerFactoryRegistry implements AutoCloseable {
|
||||
|
||||
private final Map<TenantId, EntityManagerFactory> factories = new LinkedHashMap<>();
|
||||
private final Function<TenantId, EntityManagerFactory> factoryBuilder;
|
||||
private final TenantDataSourceRegistry dataSources;
|
||||
|
||||
public TenantEntityManagerFactoryRegistry(
|
||||
Function<TenantId, EntityManagerFactory> factoryBuilder,
|
||||
TenantDataSourceRegistry dataSources) {
|
||||
this.factoryBuilder = Objects.requireNonNull(factoryBuilder, "factoryBuilder");
|
||||
this.dataSources = Objects.requireNonNull(dataSources, "dataSources");
|
||||
}
|
||||
|
||||
/** The tenant's factory, building it — and its pool — on first use. */
|
||||
public synchronized EntityManagerFactory require(TenantId tenant) {
|
||||
Objects.requireNonNull(tenant, "tenant");
|
||||
dataSources.require(tenant);
|
||||
return factories.computeIfAbsent(tenant, factoryBuilder);
|
||||
}
|
||||
|
||||
/** Closes and forgets a tenant's factory and pool. */
|
||||
public synchronized void evict(TenantId tenant) {
|
||||
EntityManagerFactory removed = factories.remove(tenant);
|
||||
if (removed != null) {
|
||||
TenantDataSourceLifecycle.drainAndClose(removed);
|
||||
}
|
||||
dataSources.evict(tenant);
|
||||
}
|
||||
|
||||
/** The tenants with an open factory. */
|
||||
public synchronized Set<TenantId> openTenants() {
|
||||
return Set.copyOf(factories.keySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
factories.values().forEach(TenantDataSourceLifecycle::drainAndClose);
|
||||
factories.clear();
|
||||
dataSources.close();
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.database;
|
||||
|
||||
/**
|
||||
* The global ceiling on per-tenant connection pools (experimental plan Task 5).
|
||||
*
|
||||
* <p>Database-per-tenant fails in a specific way: each tenant's pool is individually reasonable and
|
||||
* their sum is not. Fifty tenants with a modest ten-connection pool is five hundred connections
|
||||
* against a server whose {@code max_connections} is a hundred, and the failure arrives as
|
||||
* connection refusals for every tenant at once, including the ones that were idle.
|
||||
*
|
||||
* <p>Both ceilings are needed. A pool count alone ignores that pools differ in size; a connection
|
||||
* total alone permits an unbounded number of tiny pools, each with its own threads and monitoring.
|
||||
*/
|
||||
public record TenantPoolBudget(int maxOpenPools, int maxConnectionsAcrossPools) {
|
||||
|
||||
public TenantPoolBudget {
|
||||
if (maxOpenPools < 1) {
|
||||
throw new IllegalArgumentException("maxOpenPools must be positive");
|
||||
}
|
||||
if (maxConnectionsAcrossPools < 1) {
|
||||
throw new IllegalArgumentException("maxConnectionsAcrossPools must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when another tenant pool would exceed either ceiling.
|
||||
*
|
||||
* @throws IllegalStateException when the budget is exhausted
|
||||
*/
|
||||
public void requireCapacity(int openPools, int allocatedConnections) {
|
||||
if (openPools >= maxOpenPools || allocatedConnections >= maxConnectionsAcrossPools) {
|
||||
throw new IllegalStateException("tenant pool budget exhausted");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether another tenant pool fits inside both ceilings. */
|
||||
public boolean hasCapacity(int openPools, int allocatedConnections) {
|
||||
return openPools < maxOpenPools && allocatedConnections < maxConnectionsAcrossPools;
|
||||
}
|
||||
|
||||
/** The maximum number of tenants that can be open at once. */
|
||||
public int maxTenants() {
|
||||
return maxOpenPools;
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.next;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One forward-compatibility lane (experimental plan Tasks 7-9).
|
||||
*
|
||||
* <p>{@code publicationEnabled} is always false for a lane. A lane exists to find out whether the
|
||||
* platform still works on a newer specification, provider, or server; publishing an artifact
|
||||
* compiled in one would let a consumer depend on that answer before it exists.
|
||||
*/
|
||||
public record CompatibilityLane(
|
||||
String name, SupportLevel supportLevel, boolean publicationEnabled) {
|
||||
|
||||
public CompatibilityLane {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(supportLevel, "supportLevel");
|
||||
if (name.isBlank()) {
|
||||
throw new IllegalArgumentException("a compatibility lane requires a name");
|
||||
}
|
||||
if (publicationEnabled && supportLevel == SupportLevel.EXPERIMENTAL) {
|
||||
throw new IllegalArgumentException(
|
||||
"an experimental lane must not publish artifacts under Stable coordinates");
|
||||
}
|
||||
}
|
||||
|
||||
/** An experimental, non-publishing lane. */
|
||||
public static CompatibilityLane experimental(String name) {
|
||||
return new CompatibilityLane(name, SupportLevel.EXPERIMENTAL, false);
|
||||
}
|
||||
|
||||
/** The three lanes the experimental plan defines. */
|
||||
public static java.util.List<CompatibilityLane> defined() {
|
||||
return java.util.List.of(
|
||||
experimental("jpa4"), experimental("hibernate8"), experimental("postgresql19"));
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.next;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Decides whether an experimental feature may be considered for Stable (experimental plan Task 9).
|
||||
*
|
||||
* <p>Technical evidence is checked first, then the reviewed ADR. Both are required, and the ADR is
|
||||
* not a formality: the technical suites establish that a thing works, and the ADR records that
|
||||
* someone decided the platform should promise it — including the support burden that promise
|
||||
* carries.
|
||||
*/
|
||||
public final class ExperimentalPromotionGate {
|
||||
|
||||
/** The promotion decision for the supplied evidence. */
|
||||
public PromotionDecision evaluate(PromotionEvidence evidence) {
|
||||
Objects.requireNonNull(evidence, "evidence");
|
||||
if (!evidence.allTechnicalGatesPassed()) {
|
||||
return PromotionDecision.BLOCKED_TECHNICAL;
|
||||
}
|
||||
if (!evidence.reviewedAdr()) {
|
||||
return PromotionDecision.BLOCKED_MISSING_ADR;
|
||||
}
|
||||
return PromotionDecision.ELIGIBLE_FOR_STABLE_REVIEW;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.next;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Which provider versions are Stable and which are only exercised in a lane (experimental plan Task
|
||||
* 8).
|
||||
*
|
||||
* <p>A lane result never changes the Stable gate. If Hibernate 8 generates different SQL for the
|
||||
* collection-fetch pagination contract, that is a finding about Hibernate 8 — the 7.x gate keeps
|
||||
* asserting what 7.x must do, because that is what deployments are running.
|
||||
*/
|
||||
public final class HibernateCompatibilityPolicy {
|
||||
|
||||
private final HibernateProviderPolicy providerPolicy;
|
||||
|
||||
public HibernateCompatibilityPolicy(HibernateProviderPolicy providerPolicy) {
|
||||
this.providerPolicy = providerPolicy;
|
||||
}
|
||||
|
||||
/** A policy over the provider on this classpath. */
|
||||
public static HibernateCompatibilityPolicy fromClasspath() {
|
||||
return new HibernateCompatibilityPolicy(HibernateProviderPolicy.fromClasspath());
|
||||
}
|
||||
|
||||
/** The provider version the design declares Stable. */
|
||||
public String stableProvider() {
|
||||
return providerPolicy.stableProvider();
|
||||
}
|
||||
|
||||
/** The provider versions that may only run in a compatibility lane. */
|
||||
public List<String> experimentalProviders() {
|
||||
return providerPolicy.experimentalProviders();
|
||||
}
|
||||
|
||||
/** Whether a provider version may replace the Stable one without a promotion. */
|
||||
public boolean mayReplaceStableProvider(String version) {
|
||||
return !providerPolicy.isExperimental(version);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.next;
|
||||
|
||||
/**
|
||||
* Whether an experimental feature may be considered for Stable (experimental plan Task 9).
|
||||
*
|
||||
* <p>The blocked states are distinct because they need different work. A technical block needs
|
||||
* evidence; a missing ADR needs a decision. Collapsing them into one "not yet" hides which of the
|
||||
* two is actually outstanding.
|
||||
*/
|
||||
public enum PromotionDecision {
|
||||
|
||||
/** One or more evidence suites have not passed. */
|
||||
BLOCKED_TECHNICAL,
|
||||
|
||||
/** Every suite passed, but no reviewed ADR records the decision. */
|
||||
BLOCKED_MISSING_ADR,
|
||||
|
||||
/** Evidence and decision are both in place; Stable review may begin. */
|
||||
ELIGIBLE_FOR_STABLE_REVIEW
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.next;
|
||||
|
||||
/**
|
||||
* The evidence a promotion requires (experimental plan Task 9).
|
||||
*
|
||||
* <p>Version availability is not on this list, and its absence is the point. "PostgreSQL 19 is out"
|
||||
* is a fact about the world; "our contracts pass on it" is a fact about this platform, and only the
|
||||
* second one justifies changing what the support matrix promises.
|
||||
*/
|
||||
public record PromotionEvidence(
|
||||
boolean compatibility,
|
||||
boolean security,
|
||||
boolean failure,
|
||||
boolean migration,
|
||||
boolean performance,
|
||||
boolean reviewedAdr) {
|
||||
|
||||
/** Evidence with nothing yet gathered. */
|
||||
public static PromotionEvidence none() {
|
||||
return new PromotionEvidence(false, false, false, false, false, false);
|
||||
}
|
||||
|
||||
/** A builder-style copy with the compatibility suite recorded. */
|
||||
public PromotionEvidence withCompatibility(boolean passed) {
|
||||
return new PromotionEvidence(passed, security, failure, migration, performance, reviewedAdr);
|
||||
}
|
||||
|
||||
/** A builder-style copy with the security suite recorded. */
|
||||
public PromotionEvidence withSecurity(boolean passed) {
|
||||
return new PromotionEvidence(
|
||||
compatibility, passed, failure, migration, performance, reviewedAdr);
|
||||
}
|
||||
|
||||
/** A builder-style copy with the failure-injection suite recorded. */
|
||||
public PromotionEvidence withFailure(boolean passed) {
|
||||
return new PromotionEvidence(
|
||||
compatibility, security, passed, migration, performance, reviewedAdr);
|
||||
}
|
||||
|
||||
/** A builder-style copy with the migration suite recorded. */
|
||||
public PromotionEvidence withMigration(boolean passed) {
|
||||
return new PromotionEvidence(
|
||||
compatibility, security, failure, passed, performance, reviewedAdr);
|
||||
}
|
||||
|
||||
/** A builder-style copy with the performance suite recorded. */
|
||||
public PromotionEvidence withPerformance(boolean passed) {
|
||||
return new PromotionEvidence(compatibility, security, failure, migration, passed, reviewedAdr);
|
||||
}
|
||||
|
||||
/** A builder-style copy with the reviewed ADR recorded. */
|
||||
public PromotionEvidence withReviewedAdr(boolean reviewed) {
|
||||
return new PromotionEvidence(
|
||||
compatibility, security, failure, migration, performance, reviewed);
|
||||
}
|
||||
|
||||
/** Whether every technical suite has passed. */
|
||||
public boolean allTechnicalGatesPassed() {
|
||||
return compatibility && security && failure && migration && performance;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.replica;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Decides which database a transaction's reads go to (experimental plan Task 6).
|
||||
*
|
||||
* <p>Every rule here is a way that {@code readOnly=true} alone gets it wrong:
|
||||
*
|
||||
* <ul>
|
||||
* <li>A write transaction obviously uses the primary — but so does a read transaction that takes
|
||||
* locks, because a lock on a replica guards nothing.
|
||||
* <li>A read-after-write goes to the primary until the replica is <em>proven</em> caught up. This
|
||||
* is the case that produces "I saved it and it did not save".
|
||||
* <li>Unavailable lag evidence means the primary. Absence of evidence is not evidence of
|
||||
* freshness.
|
||||
* </ul>
|
||||
*
|
||||
* <p>The decision is made once and holds for the whole transaction. Switching mid-transaction would
|
||||
* mean two connections to two databases inside one unit of work, with no relationship between what
|
||||
* each of them sees.
|
||||
*/
|
||||
public final class ConsistencyAwareDataSourceRouter {
|
||||
|
||||
private final ReplicaLagMonitor lagMonitor;
|
||||
|
||||
public ConsistencyAwareDataSourceRouter(ReplicaLagMonitor lagMonitor) {
|
||||
this.lagMonitor = Objects.requireNonNull(lagMonitor, "lagMonitor");
|
||||
}
|
||||
|
||||
/** The target for one transaction, fixed for its whole life. */
|
||||
public ReplicaRoutingDecision route(TransactionContext transaction, ReadConsistency consistency) {
|
||||
Objects.requireNonNull(transaction, "transaction");
|
||||
Objects.requireNonNull(consistency, "consistency");
|
||||
if (transaction.write()) {
|
||||
return ReplicaRoutingDecision.primary("write transaction");
|
||||
}
|
||||
if (transaction.locking()) {
|
||||
return ReplicaRoutingDecision.primary("locking query");
|
||||
}
|
||||
if (transaction.requiresNew()) {
|
||||
return ReplicaRoutingDecision.primary("REQUIRES_NEW write boundary");
|
||||
}
|
||||
if (!lagMonitor.satisfies(consistency)) {
|
||||
return ReplicaRoutingDecision.primary("replica lag evidence does not satisfy the read");
|
||||
}
|
||||
return ReplicaRoutingDecision.replica("consistency requirement satisfied by the replica");
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.replica;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A marker for "the state as of this write" (experimental plan Task 6).
|
||||
*
|
||||
* <p>Read-after-write cannot be answered by {@code readOnly=true}. A read that follows a write is
|
||||
* still a read, and routing it to a replica is exactly how a user saves a form and then sees the
|
||||
* old value. The token is what lets the router ask the real question — has the replica caught up
|
||||
* past this point — instead of guessing from the transaction's read-only flag.
|
||||
*/
|
||||
public record ConsistencyToken(Instant writtenAt, String logPosition) {
|
||||
|
||||
public ConsistencyToken {
|
||||
Objects.requireNonNull(writtenAt, "writtenAt");
|
||||
logPosition = logPosition == null ? "" : logPosition;
|
||||
}
|
||||
|
||||
/** A token for a write observed at {@code writtenAt} with no log position available. */
|
||||
public static ConsistencyToken at(Instant writtenAt) {
|
||||
return new ConsistencyToken(writtenAt, null);
|
||||
}
|
||||
|
||||
/** Whether a replica reporting {@code replicaPosition} has caught up past this token. */
|
||||
public boolean satisfiedBy(Instant replicaPosition) {
|
||||
Objects.requireNonNull(replicaPosition, "replicaPosition");
|
||||
return !replicaPosition.isBefore(writtenAt);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.replica;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* How fresh a read has to be (experimental plan Task 6).
|
||||
*
|
||||
* <p>The consistency requirement is stated by the caller, because only the caller knows it. A
|
||||
* dashboard tolerates seconds of lag; the read that renders the page after a save does not; a read
|
||||
* that will be used to compute a write must not go to a replica at all.
|
||||
*/
|
||||
public record ReadConsistency(
|
||||
Level level, Optional<ConsistencyToken> after, Duration maxStaleness) {
|
||||
|
||||
/** The consistency levels a read may ask for. */
|
||||
public enum Level {
|
||||
/** Must see this session's own writes; the primary, or a replica proven to be caught up. */
|
||||
PRIMARY_REQUIRED,
|
||||
|
||||
/** May use a replica whose lag is within {@code maxStaleness}. */
|
||||
BOUNDED_STALENESS,
|
||||
|
||||
/** May use any replica. */
|
||||
EVENTUAL
|
||||
}
|
||||
|
||||
public ReadConsistency {
|
||||
Objects.requireNonNull(level, "level");
|
||||
Objects.requireNonNull(after, "after");
|
||||
Objects.requireNonNull(maxStaleness, "maxStaleness");
|
||||
if (maxStaleness.isNegative()) {
|
||||
throw new IllegalArgumentException("max staleness must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/** A read that must observe the write identified by {@code token}. */
|
||||
public static ReadConsistency after(ConsistencyToken token) {
|
||||
return new ReadConsistency(Level.PRIMARY_REQUIRED, Optional.of(token), Duration.ZERO);
|
||||
}
|
||||
|
||||
/** A read that tolerates up to {@code maxStaleness} of replica lag. */
|
||||
public static ReadConsistency boundedStaleness(Duration maxStaleness) {
|
||||
return new ReadConsistency(Level.BOUNDED_STALENESS, Optional.empty(), maxStaleness);
|
||||
}
|
||||
|
||||
/** A read that tolerates any lag. */
|
||||
public static ReadConsistency eventual() {
|
||||
return new ReadConsistency(Level.EVENTUAL, Optional.empty(), Duration.ZERO);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.replica;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reports how far a replica is behind (experimental plan Task 6).
|
||||
*
|
||||
* <p>{@link #replayedThrough()} is an {@link Optional} on purpose. When lag cannot be measured —
|
||||
* the monitor is down, the replica is unreachable, the metric is stale — the correct answer is "I
|
||||
* do not know", and the router must treat that as a reason to use the primary. A monitor that
|
||||
* returned zero lag on failure would route reads to a replica precisely when something is wrong
|
||||
* with it.
|
||||
*/
|
||||
public interface ReplicaLagMonitor {
|
||||
|
||||
/** The point in time the replica has replayed through, when it can be measured. */
|
||||
Optional<Instant> replayedThrough();
|
||||
|
||||
/** The measured lag, when it can be measured. */
|
||||
Optional<Duration> lag();
|
||||
|
||||
/**
|
||||
* Whether the replica provably satisfies the consistency requirement.
|
||||
*
|
||||
* <p>Defaults to {@code false} whenever the evidence is unavailable.
|
||||
*/
|
||||
default boolean satisfies(ReadConsistency consistency) {
|
||||
return switch (consistency.level()) {
|
||||
case EVENTUAL -> true;
|
||||
case BOUNDED_STALENESS ->
|
||||
lag().map(measured -> measured.compareTo(consistency.maxStaleness()) <= 0).orElse(false);
|
||||
case PRIMARY_REQUIRED ->
|
||||
consistency
|
||||
.after()
|
||||
.flatMap(token -> replayedThrough().map(token::satisfiedBy))
|
||||
.orElse(false);
|
||||
};
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.replica;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Where one transaction's reads go, and why (experimental plan Task 6).
|
||||
*
|
||||
* <p>The reason is carried so a routing decision is explainable after the fact. "Why did this read
|
||||
* see stale data" is otherwise unanswerable from logs.
|
||||
*/
|
||||
public record ReplicaRoutingDecision(ReplicaTarget target, String reason) {
|
||||
|
||||
public ReplicaRoutingDecision {
|
||||
Objects.requireNonNull(target, "target");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (reason.isBlank()) {
|
||||
throw new IllegalArgumentException("a routing decision requires a reason");
|
||||
}
|
||||
}
|
||||
|
||||
/** Route to the primary. */
|
||||
public static ReplicaRoutingDecision primary(String reason) {
|
||||
return new ReplicaRoutingDecision(ReplicaTarget.PRIMARY, reason);
|
||||
}
|
||||
|
||||
/** Route to a replica. */
|
||||
public static ReplicaRoutingDecision replica(String reason) {
|
||||
return new ReplicaRoutingDecision(ReplicaTarget.REPLICA, reason);
|
||||
}
|
||||
|
||||
/** Whether this decision routes to the primary. */
|
||||
public boolean usesPrimary() {
|
||||
return target == ReplicaTarget.PRIMARY;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.replica;
|
||||
|
||||
/** Which database a read is routed to (experimental plan Task 6). */
|
||||
public enum ReplicaTarget {
|
||||
|
||||
/** The primary. Every write, every lock, and every read that cannot tolerate lag. */
|
||||
PRIMARY,
|
||||
|
||||
/** A read replica, chosen only when the consistency requirement provably allows it. */
|
||||
REPLICA
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.replica;
|
||||
|
||||
/**
|
||||
* What the router needs to know about the transaction it is routing (experimental plan Task 6).
|
||||
*
|
||||
* <p>{@code write} and {@code locking} are separate because a read-only transaction can still take
|
||||
* locks — {@code SELECT ... FOR UPDATE} in a read-only transaction is legal — and a lock taken on a
|
||||
* replica protects nothing, since the write it is guarding will happen on the primary.
|
||||
*/
|
||||
public record TransactionContext(boolean write, boolean locking, boolean requiresNew) {
|
||||
|
||||
/** An ordinary read transaction. */
|
||||
public static TransactionContext readOnly() {
|
||||
return new TransactionContext(false, false, false);
|
||||
}
|
||||
|
||||
/** A read transaction that takes locks. */
|
||||
public static TransactionContext lockingRead() {
|
||||
return new TransactionContext(false, true, false);
|
||||
}
|
||||
|
||||
/** A write transaction. */
|
||||
public static TransactionContext writing() {
|
||||
return new TransactionContext(true, false, false);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.rls;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The audited identity a cross-tenant RLS bypass runs under (experimental plan Task 3).
|
||||
*
|
||||
* <p>A bypass needs a separate DataSource, connected as a role that RLS does not apply to. Sharing
|
||||
* the runtime DataSource would mean the bypass role is the role every ordinary request already
|
||||
* uses, at which point the policies protect nothing.
|
||||
*/
|
||||
public record RlsAdminBypassToken(String operator, String reason) {
|
||||
|
||||
private static final Pattern OPERATOR = Pattern.compile("[A-Za-z0-9._:-]{1,64}");
|
||||
private static final int MAX_REASON_LENGTH = 256;
|
||||
|
||||
public RlsAdminBypassToken {
|
||||
Objects.requireNonNull(operator, "operator");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (!OPERATOR.matcher(operator).matches()) {
|
||||
throw new IllegalArgumentException("invalid bypass operator identity");
|
||||
}
|
||||
if (reason.isBlank() || reason.length() > MAX_REASON_LENGTH) {
|
||||
throw new IllegalArgumentException("a bypass requires a present, bounded reason");
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.rls;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* Proves the runtime role cannot bypass row-level security (experimental plan Task 3).
|
||||
*
|
||||
* <p>Three ways RLS silently does nothing, all checked here:
|
||||
*
|
||||
* <ul>
|
||||
* <li>The table has no policy, or RLS was never enabled on it.
|
||||
* <li>The runtime role has {@code BYPASSRLS}.
|
||||
* <li>The runtime role <em>owns</em> the table — table owners are exempt from RLS unless the
|
||||
* table is set to {@code FORCE ROW LEVEL SECURITY}, which is the one people forget.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Each of those produces a system where every query returns every tenant's rows while the
|
||||
* policies look correctly configured.
|
||||
*/
|
||||
public final class RlsPolicyVerifier {
|
||||
|
||||
private static final String ROLE_QUERY =
|
||||
"select rolbypassrls from pg_roles where rolname = current_user";
|
||||
|
||||
private static final String TABLE_QUERY =
|
||||
"""
|
||||
select c.relname, c.relrowsecurity, c.relforcerowsecurity,
|
||||
pg_get_userbyid(c.relowner) = current_user as owned_by_current_user
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = current_schema() and c.relkind = 'r'
|
||||
""";
|
||||
|
||||
/**
|
||||
* Fails when RLS is not actually enforced for the runtime role.
|
||||
*
|
||||
* @param tenantScopedTables the tables that must be tenant isolated
|
||||
* @throws IllegalStateException naming the first way isolation is not enforced
|
||||
*/
|
||||
public void requireEnforced(DataSource runtimeDataSource, List<String> tenantScopedTables) {
|
||||
Objects.requireNonNull(runtimeDataSource, "runtimeDataSource");
|
||||
Objects.requireNonNull(tenantScopedTables, "tenantScopedTables");
|
||||
try (Connection connection = runtimeDataSource.getConnection()) {
|
||||
requireNoBypassPrivilege(connection);
|
||||
requirePoliciesForced(connection, tenantScopedTables);
|
||||
} catch (SQLException failure) {
|
||||
throw new IllegalStateException("row level security could not be verified", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** The tables in the current schema that do not force RLS. */
|
||||
public List<String> tablesWithoutForcedPolicy(DataSource dataSource) {
|
||||
List<String> unforced = new ArrayList<>();
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(TABLE_QUERY);
|
||||
ResultSet rows = statement.executeQuery()) {
|
||||
while (rows.next()) {
|
||||
boolean enforced =
|
||||
rows.getBoolean("relrowsecurity")
|
||||
&& (rows.getBoolean("relforcerowsecurity")
|
||||
|| !rows.getBoolean("owned_by_current_user"));
|
||||
if (!enforced) {
|
||||
unforced.add(rows.getString("relname"));
|
||||
}
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
throw new IllegalStateException("row level security state could not be read", failure);
|
||||
}
|
||||
return List.copyOf(unforced);
|
||||
}
|
||||
|
||||
private static void requireNoBypassPrivilege(Connection connection) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(ROLE_QUERY);
|
||||
ResultSet rows = statement.executeQuery()) {
|
||||
if (rows.next() && rows.getBoolean(1)) {
|
||||
throw new IllegalStateException(
|
||||
"the runtime role holds BYPASSRLS, so every row level security policy is inert");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePoliciesForced(Connection connection, List<String> tenantScopedTables)
|
||||
throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(TABLE_QUERY);
|
||||
ResultSet rows = statement.executeQuery()) {
|
||||
while (rows.next()) {
|
||||
String table = rows.getString("relname");
|
||||
if (!tenantScopedTables.contains(table)) {
|
||||
continue;
|
||||
}
|
||||
if (!rows.getBoolean("relrowsecurity")) {
|
||||
throw new IllegalStateException(
|
||||
"table '" + table + "' is tenant scoped but has row level security disabled");
|
||||
}
|
||||
if (rows.getBoolean("owned_by_current_user") && !rows.getBoolean("relforcerowsecurity")) {
|
||||
throw new IllegalStateException(
|
||||
"table '"
|
||||
+ table
|
||||
+ "' is owned by the runtime role and does not FORCE ROW LEVEL"
|
||||
+ " SECURITY, so the owner is exempt from its own policies");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.rls;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Binds the tenant into a PostgreSQL transaction-local setting for RLS to read (experimental plan
|
||||
* Task 3).
|
||||
*
|
||||
* <p>The {@code true} third argument to {@code set_config} is the whole safety property: it makes
|
||||
* the setting <em>transaction-local</em>, so it is discarded when the transaction ends. A
|
||||
* session-local setting would survive the connection's return to the pool, and the next transaction
|
||||
* on that connection — quite possibly another tenant's — would inherit it and pass every RLS policy
|
||||
* as the previous tenant.
|
||||
*
|
||||
* <p>The tenant is bound as a parameter, never concatenated. A tenant id is externally influenced
|
||||
* data, and {@code set_config} takes a string.
|
||||
*/
|
||||
public final class RlsTenantSessionBinder {
|
||||
|
||||
/** The setting RLS policies read the current tenant from. */
|
||||
public static final String TENANT_SETTING = "app.tenant_id";
|
||||
|
||||
/** Transaction-local binding; the {@code true} argument is what scopes it to the transaction. */
|
||||
private static final String BIND_SQL = "select set_config('app.tenant_id', ?, true)";
|
||||
|
||||
/** Binds {@code tenant} for the remainder of the current transaction. */
|
||||
public void bind(EntityManager entityManager, TenantId tenant) {
|
||||
Objects.requireNonNull(entityManager, "entityManager");
|
||||
Objects.requireNonNull(tenant, "tenant");
|
||||
entityManager.createNativeQuery(BIND_SQL).setParameter(1, tenant.value()).getSingleResult();
|
||||
}
|
||||
|
||||
/** The SQL this binder issues; exposed so the safety check can assert it is parameterized. */
|
||||
public static String bindStatement() {
|
||||
return BIND_SQL;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.schema;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Objects;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* Selects a tenant's schema on a pooled connection, and resets it on return (experimental plan Task
|
||||
* 4).
|
||||
*
|
||||
* <p>The reset is the part that matters. {@code search_path} is a session setting, so a connection
|
||||
* returned to the pool still carries the last tenant's schema. The next borrower — quite possibly a
|
||||
* different tenant, or a background job with no tenant at all — reads and writes there without any
|
||||
* statement being wrong.
|
||||
*
|
||||
* <p>The schema comes from the registry and is validated as an identifier there, because {@code SET
|
||||
* search_path} takes an identifier and not a bound parameter.
|
||||
*/
|
||||
public final class SchemaMultiTenantConnectionProvider {
|
||||
|
||||
/** The schema a connection is reset to when it is released. */
|
||||
public static final String NEUTRAL_SCHEMA = "pg_catalog";
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final SchemaTenantRegistry registry;
|
||||
|
||||
public SchemaMultiTenantConnectionProvider(DataSource dataSource, SchemaTenantRegistry registry) {
|
||||
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
|
||||
this.registry = Objects.requireNonNull(registry, "registry");
|
||||
}
|
||||
|
||||
/** Borrows a connection with {@code tenant}'s schema selected. */
|
||||
public Connection getConnection(TenantId tenant) throws SQLException {
|
||||
Objects.requireNonNull(tenant, "tenant");
|
||||
String schema = registry.requireSchema(tenant);
|
||||
Connection connection = dataSource.getConnection();
|
||||
try {
|
||||
setSearchPath(connection, schema);
|
||||
return connection;
|
||||
} catch (SQLException failure) {
|
||||
connection.close();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets the schema before the connection goes back to the pool. */
|
||||
public void releaseConnection(Connection connection) throws SQLException {
|
||||
Objects.requireNonNull(connection, "connection");
|
||||
try {
|
||||
setSearchPath(connection, NEUTRAL_SCHEMA);
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a registered schema identifier.
|
||||
*
|
||||
* <p>{@code set_config} is used rather than {@code SET search_path} because it accepts the value
|
||||
* as a bound parameter; the identifier has already been validated by the registry, and binding it
|
||||
* removes the last route by which a schema name could reach the statement text.
|
||||
*/
|
||||
private static void setSearchPath(Connection connection, String schema) throws SQLException {
|
||||
try (PreparedStatement statement =
|
||||
connection.prepareStatement("select set_config('search_path', ?, false)")) {
|
||||
statement.setString(1, schema);
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.schema;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import javax.sql.DataSource;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationInfo;
|
||||
import org.flywaydb.core.api.output.MigrateResult;
|
||||
|
||||
/**
|
||||
* Migrates each tenant schema, tracking status per tenant (experimental plan Task 4).
|
||||
*
|
||||
* <p>Per-tenant status is not bookkeeping. With one schema per tenant, a migration run is N
|
||||
* independent migrations, and one failing does not stop the others from having succeeded — so "the
|
||||
* migration failed" is not a usable answer. A resumable run needs to know which tenants are already
|
||||
* done.
|
||||
*
|
||||
* <p>Failures are recorded and the run continues, but nothing is repaired automatically: a checksum
|
||||
* mismatch on one tenant is the same evidence it is anywhere else, and repairing it here would
|
||||
* erase it N times over.
|
||||
*/
|
||||
public final class SchemaTenantMigrationOrchestrator {
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final SchemaTenantRegistry registry;
|
||||
private final String migrationLocation;
|
||||
private final Map<TenantId, TenantMigrationStatus> status = new LinkedHashMap<>();
|
||||
|
||||
public SchemaTenantMigrationOrchestrator(
|
||||
DataSource dataSource, SchemaTenantRegistry registry, String migrationLocation) {
|
||||
this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
|
||||
this.registry = Objects.requireNonNull(registry, "registry");
|
||||
this.migrationLocation = Objects.requireNonNull(migrationLocation, "migrationLocation");
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates one registered tenant's schema.
|
||||
*
|
||||
* @throws IllegalArgumentException when the tenant has no registered schema
|
||||
*/
|
||||
public MigrateResult migrate(TenantId tenant) {
|
||||
String schema = registry.requireSchema(tenant);
|
||||
Flyway flyway =
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.schemas(schema)
|
||||
.defaultSchema(schema)
|
||||
.locations(migrationLocation)
|
||||
.load();
|
||||
MigrateResult result = flyway.migrate();
|
||||
status.put(tenant, new TenantMigrationStatus(tenant, appliedVersion(flyway), null));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The version this tenant's schema is actually on, read back from its schema history.
|
||||
*
|
||||
* <p>{@code MigrateResult.targetSchemaVersion} is not that: a run that applied nothing because
|
||||
* the schema was already current leaves it empty, and recording empty would report a migrated
|
||||
* tenant as unmigrated. The status exists to answer "which tenants are on which version" during a
|
||||
* partial rollout, so it has to come from the history table rather than from what this particular
|
||||
* invocation happened to do.
|
||||
*/
|
||||
private static String appliedVersion(Flyway flyway) {
|
||||
MigrationInfo current = flyway.info().current();
|
||||
if (current == null || current.getVersion() == null) {
|
||||
return null;
|
||||
}
|
||||
return current.getVersion().getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates every supplied tenant, recording per-tenant outcomes.
|
||||
*
|
||||
* <p>A failure is recorded rather than thrown so the remaining tenants still run; the caller
|
||||
* inspects {@link #failed()} afterwards.
|
||||
*/
|
||||
public void migrateAll(List<TenantId> tenants) {
|
||||
Objects.requireNonNull(tenants, "tenants");
|
||||
for (TenantId tenant : tenants) {
|
||||
try {
|
||||
migrate(tenant);
|
||||
} catch (RuntimeException failure) {
|
||||
status.put(
|
||||
tenant, new TenantMigrationStatus(tenant, null, failure.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The recorded status for one tenant. */
|
||||
public Optional<TenantMigrationStatus> status(TenantId tenant) {
|
||||
return Optional.ofNullable(status.get(tenant));
|
||||
}
|
||||
|
||||
/** The tenants whose migration failed in the last run. */
|
||||
public List<TenantMigrationStatus> failed() {
|
||||
return status.values().stream().filter(TenantMigrationStatus::failed).toList();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.schema;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Maps a tenant to a pre-registered schema identifier (experimental plan Task 4).
|
||||
*
|
||||
* <p>A schema name is part of the SQL statement — it cannot be bound as a parameter — so a
|
||||
* deployment that derived it from a tenant id would be building SQL from externally influenced
|
||||
* data. Registration keeps the set of reachable schemas fixed at deployment time, and a tenant that
|
||||
* is not in the map cannot address any schema at all.
|
||||
*/
|
||||
public final class SchemaTenantRegistry {
|
||||
|
||||
/** Unquoted PostgreSQL schema identifiers only. */
|
||||
private static final Pattern SCHEMA = Pattern.compile("[a-z_][a-z0-9_]{0,62}");
|
||||
|
||||
private final Map<TenantId, String> schemaByTenant;
|
||||
|
||||
public SchemaTenantRegistry(Map<TenantId, String> schemaByTenant) {
|
||||
Objects.requireNonNull(schemaByTenant, "schemaByTenant");
|
||||
schemaByTenant.forEach(
|
||||
(tenant, schema) -> {
|
||||
Objects.requireNonNull(tenant, "tenant");
|
||||
if (schema == null || !SCHEMA.matcher(schema).matches()) {
|
||||
throw new IllegalArgumentException("invalid tenant schema identifier: " + schema);
|
||||
}
|
||||
});
|
||||
this.schemaByTenant = Map.copyOf(schemaByTenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema registered for {@code tenant}.
|
||||
*
|
||||
* @throws IllegalArgumentException when the tenant has no registered schema
|
||||
*/
|
||||
public String requireSchema(TenantId tenant) {
|
||||
Objects.requireNonNull(tenant, "tenant");
|
||||
return Optional.ofNullable(schemaByTenant.get(tenant))
|
||||
.orElseThrow(() -> new IllegalArgumentException("unregistered tenant schema"));
|
||||
}
|
||||
|
||||
/** Whether a tenant has a registered schema. */
|
||||
public boolean contains(TenantId tenant) {
|
||||
return schemaByTenant.containsKey(tenant);
|
||||
}
|
||||
|
||||
/** The registered tenants. */
|
||||
public Set<TenantId> tenants() {
|
||||
return schemaByTenant.keySet();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.schema;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The outcome of migrating one tenant's schema (experimental plan Task 4).
|
||||
*
|
||||
* @param failureType the exception's simple name, never its message — a Flyway failure message
|
||||
* contains the script path and part of the failing statement
|
||||
*/
|
||||
public record TenantMigrationStatus(TenantId tenant, String version, String failureType) {
|
||||
|
||||
public TenantMigrationStatus {
|
||||
Objects.requireNonNull(tenant, "tenant");
|
||||
}
|
||||
|
||||
/** Whether this tenant's migration failed. */
|
||||
public boolean failed() {
|
||||
return failureType != null;
|
||||
}
|
||||
|
||||
/** The applied schema version, when the migration succeeded. */
|
||||
public Optional<String> appliedVersion() {
|
||||
return Optional.ofNullable(version);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.tenant;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Refuses repository access without a tenant, outside an audited admin scope (experimental plan
|
||||
* Task 2).
|
||||
*
|
||||
* <p>The guard exists because a Hibernate filter is not a security boundary. Filters apply to
|
||||
* entity queries, and they do not apply to native SQL, to bulk DML, to {@code getReference}, or to
|
||||
* anything reached through a second-level cache — so a design that relied on the filter alone
|
||||
* leaves several routes to another tenant's rows wide open.
|
||||
*
|
||||
* <p>The admin scope is explicit and audited for the same reason: a cross-tenant read is sometimes
|
||||
* legitimate, and the way to keep it legitimate is to make it visible.
|
||||
*/
|
||||
public final class TenantAwareRepositoryGuard {
|
||||
|
||||
private static final ThreadLocal<String> ADMIN_SCOPE = new ThreadLocal<>();
|
||||
|
||||
/**
|
||||
* Fails when no tenant is bound and no admin scope is open.
|
||||
*
|
||||
* @throws IllegalStateException naming what is missing
|
||||
*/
|
||||
public void requireTenantOrAdminScope() {
|
||||
if (TenantContext.current().isPresent() || ADMIN_SCOPE.get() != null) {
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"tenant context is required for repository access; a cross-tenant read must open an"
|
||||
+ " audited admin scope instead");
|
||||
}
|
||||
|
||||
/** Opens an audited cross-tenant scope for {@code work}. */
|
||||
public <T> T inAdminScope(String auditReason, java.util.function.Supplier<T> work) {
|
||||
Objects.requireNonNull(auditReason, "auditReason");
|
||||
Objects.requireNonNull(work, "work");
|
||||
if (auditReason.isBlank()) {
|
||||
throw new IllegalArgumentException("an admin scope requires an audit reason");
|
||||
}
|
||||
String previous = ADMIN_SCOPE.get();
|
||||
ADMIN_SCOPE.set(auditReason);
|
||||
try {
|
||||
return work.get();
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
ADMIN_SCOPE.remove();
|
||||
} else {
|
||||
ADMIN_SCOPE.set(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an audited cross-tenant scope is currently open. */
|
||||
public boolean adminScopeOpen() {
|
||||
return ADMIN_SCOPE.get() != null;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.tenant;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The tenant the current unit of work belongs to (experimental plan Task 2).
|
||||
*
|
||||
* <p>Fail-closed: {@link #require()} throws when no tenant is bound rather than returning a default
|
||||
* or null. In a shared-schema deployment, an unbound tenant means a query with no tenant predicate,
|
||||
* which returns every tenant's rows — a cross-tenant data leak that looks like a successful
|
||||
* request.
|
||||
*
|
||||
* <p>Clearing is mandatory. Request threads are pooled, so a leaked tenant is not merely stale
|
||||
* state: the next request on that thread reads and writes as the previous request's tenant.
|
||||
*/
|
||||
public final class TenantContext {
|
||||
|
||||
private static final ThreadLocal<TenantId> CURRENT = new ThreadLocal<>();
|
||||
|
||||
private TenantContext() {}
|
||||
|
||||
/** Binds a tenant to the current thread. */
|
||||
public static void bind(TenantId tenant) {
|
||||
CURRENT.set(Objects.requireNonNull(tenant, "tenant"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The bound tenant.
|
||||
*
|
||||
* @throws IllegalStateException when no tenant is bound
|
||||
*/
|
||||
public static TenantId require() {
|
||||
TenantId tenant = CURRENT.get();
|
||||
if (tenant == null) {
|
||||
throw new IllegalStateException("tenant context is required");
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/** The bound tenant, when one is bound. */
|
||||
public static Optional<TenantId> current() {
|
||||
return Optional.ofNullable(CURRENT.get());
|
||||
}
|
||||
|
||||
/** Unbinds the current tenant. Callers must invoke this in a {@code finally}. */
|
||||
public static void clear() {
|
||||
CURRENT.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@code work} with {@code tenant} bound, restoring the previous binding afterwards.
|
||||
*
|
||||
* <p>Restoring rather than clearing is what makes this safe to nest — an async job that adopts a
|
||||
* tenant inside a request must not leave the request's own tenant unbound when it finishes.
|
||||
*/
|
||||
public static <T> T with(TenantId tenant, Supplier<T> work) {
|
||||
Objects.requireNonNull(work, "work");
|
||||
TenantId previous = CURRENT.get();
|
||||
bind(tenant);
|
||||
try {
|
||||
return work.get();
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
CURRENT.remove();
|
||||
} else {
|
||||
CURRENT.set(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.tenant;
|
||||
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import java.util.Objects;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Stamps and verifies the tenant column on every write (experimental plan Task 2).
|
||||
*
|
||||
* <p>Reads are only half the isolation problem. A write with the wrong tenant column — or with none
|
||||
* — inserts a row that the writing tenant cannot see and another tenant can. The listener stamps
|
||||
* the bound tenant on insert and refuses an update that would move a row between tenants.
|
||||
*
|
||||
* @param <T> the tenant-scoped entity type
|
||||
*/
|
||||
public final class TenantEntityListenerGuard<T> {
|
||||
|
||||
private final Function<T, TenantId> tenantReader;
|
||||
private final BiConsumer<T, TenantId> tenantWriter;
|
||||
|
||||
public TenantEntityListenerGuard(
|
||||
Function<T, TenantId> tenantReader, BiConsumer<T, TenantId> tenantWriter) {
|
||||
this.tenantReader = Objects.requireNonNull(tenantReader, "tenantReader");
|
||||
this.tenantWriter = Objects.requireNonNull(tenantWriter, "tenantWriter");
|
||||
}
|
||||
|
||||
/** Stamps the bound tenant onto a new row. */
|
||||
@PrePersist
|
||||
public void stampOnInsert(T entity) {
|
||||
Objects.requireNonNull(entity, "entity");
|
||||
TenantId bound = TenantContext.require();
|
||||
TenantId existing = tenantReader.apply(entity);
|
||||
if (existing == null) {
|
||||
tenantWriter.accept(entity, bound);
|
||||
return;
|
||||
}
|
||||
requireSameTenant(existing, bound);
|
||||
}
|
||||
|
||||
/** Refuses an update that would move a row to another tenant. */
|
||||
@PreUpdate
|
||||
public void verifyOnUpdate(T entity) {
|
||||
Objects.requireNonNull(entity, "entity");
|
||||
requireSameTenant(tenantReader.apply(entity), TenantContext.require());
|
||||
}
|
||||
|
||||
private static void requireSameTenant(TenantId entityTenant, TenantId boundTenant) {
|
||||
if (entityTenant == null || !entityTenant.equals(boundTenant)) {
|
||||
throw new IllegalStateException(
|
||||
"a write must stay within the bound tenant; cross-tenant writes are refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.experimental.tenant;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A tenant identity (experimental plan Task 2).
|
||||
*
|
||||
* <p>The format is bounded because a tenant id ends up in a schema name, a {@code set_config}
|
||||
* value, and a routing key. A tenant id like {@code ../public} is what turns schema-per-tenant into
|
||||
* a path-traversal problem, so the shape is validated at the type rather than at each use.
|
||||
*
|
||||
* <p>The value never becomes a metric tag: tenant cardinality is unbounded by definition, and a
|
||||
* tenant id in telemetry is customer data in a system that is rarely treated as one.
|
||||
*/
|
||||
public record TenantId(String value) {
|
||||
|
||||
private static final Pattern FORMAT = Pattern.compile("[a-z0-9][a-z0-9_-]{1,62}");
|
||||
|
||||
public TenantId {
|
||||
if (value == null || !FORMAT.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("invalid tenant id");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable;
|
||||
* H2 atomic scope claim.
|
||||
*
|
||||
* <p>H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL
|
||||
* statement does not port. The standard {@code MERGE ... USING} does, and carries the same
|
||||
* meaning in one statement:
|
||||
* statement does not port. The standard {@code MERGE ... USING} does, and carries the same meaning
|
||||
* in one statement:
|
||||
*
|
||||
* <ul>
|
||||
* <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row);
|
||||
|
||||
+6
-6
@@ -15,12 +15,12 @@ import org.springframework.jdbc.core.JdbcOperations;
|
||||
* <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)}
|
||||
* — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives
|
||||
* the transaction on a pooled connection. It is not left stale in practice because the
|
||||
* transaction port applies these before every transaction, so each one overwrites the last;
|
||||
* a connection borrowed outside that path keeps the previous transaction's guard.
|
||||
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to
|
||||
* {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the
|
||||
* database here. It is left to the caller-side deadline the transaction port already
|
||||
* enforces, rather than silently reported as applied.
|
||||
* transaction port applies these before every transaction, so each one overwrites the last; a
|
||||
* connection borrowed outside that path keeps the previous transaction's guard.
|
||||
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to {@code
|
||||
* idle_in_transaction_session_timeout}, so that budget cannot be pushed into the database
|
||||
* here. It is left to the caller-side deadline the transaction port already enforces, rather
|
||||
* than silently reported as applied.
|
||||
* </ul>
|
||||
*
|
||||
* <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They
|
||||
|
||||
+8
-8
@@ -15,13 +15,13 @@ import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
/**
|
||||
* H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers,
|
||||
* implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the
|
||||
* {@code local} profile sets.
|
||||
* implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the {@code
|
||||
* local} profile sets.
|
||||
*
|
||||
* <p><b>No Flyway location customizer, deliberately.</b> The PostgreSQL vendor points Flyway at
|
||||
* {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local
|
||||
* profile turns Flyway off and lets Hibernate derive the schema from the entities. Two
|
||||
* consequences worth stating out loud:
|
||||
* profile turns Flyway off and lets Hibernate derive the schema from the entities. Two consequences
|
||||
* worth stating out loud:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery
|
||||
@@ -30,12 +30,12 @@ import org.springframework.jdbc.core.JdbcOperations;
|
||||
* there will fail on a missing table rather than silently misbehave.
|
||||
* <li>A fork that enables Flyway while this vendor is selected gets no location override, so
|
||||
* Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including
|
||||
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own
|
||||
* {@code FlywayConfigurationCustomizer} naming an H2 location.
|
||||
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own {@code
|
||||
* FlywayConfigurationCustomizer} naming an H2 location.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency
|
||||
* fidelity stay with the real-PostgreSQL integration suites.
|
||||
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and
|
||||
* vendor-concurrency fidelity stay with the real-PostgreSQL integration suites.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user