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
@@ -24,7 +24,13 @@ readonly EXPECTED_WORKFLOW_LOCK=(
|
||||
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
|
||||
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
|
||||
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
|
||||
'e0cb998969b8f4b8531f50d38413ee4640931839be7ad50509e1d0e1a84f919e .github/workflows/jpa-next-hibernate8.yml'
|
||||
'6f577e71cd10d74facdf76353f211132d2e2ba04363b8025affb45873f349d9f .github/workflows/jpa-next-jpa4.yml'
|
||||
'f69e174cd0e5a2451078ea23d52efefc13fb27d2c120f5fe30dc93ffc4d532aa .github/workflows/jpa-next-postgresql19.yml'
|
||||
'053593c3f1b5acdc98f01f1986ffbe74163d61c58384d16e27949b879757bef1 .github/workflows/jpa-nightly.yml'
|
||||
'04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml'
|
||||
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
|
||||
'ea7f8214a3cc9ec3e7ba3183a2201fd26a05a61f0b0fdcb1f041b71efca3e81c .github/workflows/jpa-release.yml'
|
||||
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
|
||||
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
|
||||
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: jpa-next-hibernate8
|
||||
|
||||
# Hibernate ORM 8 compatibility lane (experimental plan Task 8).
|
||||
#
|
||||
# Re-runs the contracts most likely to move between provider majors: collection fetch pagination,
|
||||
# StatementInspector, Statistics, JSONB, batch, and StatelessSession. Differences are recorded, not
|
||||
# accommodated — weakening the 7.x gate to make this lane green would delete the evidence that 7.x
|
||||
# behaves as documented.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
hibernate8-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report Hibernate ORM 8 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*HibernateCompatibilityPolicyTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,43 @@
|
||||
name: jpa-next-jpa4
|
||||
|
||||
# Jakarta Persistence 4.0 compatibility lane (experimental plan Task 7).
|
||||
#
|
||||
# Non-blocking by design: it reports whether the Stable public API still compiles and whether the
|
||||
# selected mapping contracts still hold on JPA 4. It publishes nothing, and a red result here never
|
||||
# changes a Stable contract — the 3.2 gate keeps asserting what 3.2 must do, because that is what
|
||||
# deployments run.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 4 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
jpa4-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report Jakarta Persistence 4.0 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*CompatibilityLaneDefinitionTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,42 @@
|
||||
name: jpa-next-postgresql19
|
||||
|
||||
# PostgreSQL 19 compatibility lane (experimental plan Task 9).
|
||||
#
|
||||
# Promotion needs evidence, not availability. Two supported patch runs with no unresolved semantic
|
||||
# regression, plus a reviewed ADR, before the Stable support matrix changes — which is what
|
||||
# ExperimentalPromotionGate encodes.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
postgresql19-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report PostgreSQL 19 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*ExperimentalPromotionGateTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,131 @@
|
||||
name: jpa-nightly
|
||||
|
||||
# The suites that are too slow, too Docker-heavy, or too machine-dependent for a PR, and the middle
|
||||
# of the PostgreSQL matrix.
|
||||
#
|
||||
# The failure-injection lane is the one that matters most and is easiest to lose: it is the only
|
||||
# place the commit-ambiguity scenarios run, and they are the only evidence that a lost commit
|
||||
# acknowledgement produces completion-unknown rather than a retry.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 02:30 UTC daily.
|
||||
- cron: '30 2 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
jpa-full-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
postgresql: ["16", "17", "18"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-failure-injection:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformFailureTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-query-plan-and-security:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the query plan and database security suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-pool-pressure:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Measure pool saturation and REQUIRES_NEW pressure
|
||||
working-directory: src
|
||||
# Machine-dependent bounds are reported rather than asserted unless explicitly enabled, so a
|
||||
# noisy shared runner does not produce a red build that means nothing.
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest
|
||||
-Pperformance.assertions.enabled=false
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,114 @@
|
||||
name: jpa-pr
|
||||
|
||||
# Every "Stable" row in docs/jpa/support-matrix.md is backed by a job here or in jpa-nightly /
|
||||
# jpa-release. A support level with no job behind it is a marketing claim.
|
||||
#
|
||||
# The PR lane runs the oldest and the newest Stable PostgreSQL rather than all three: a behaviour
|
||||
# that differs across the matrix almost always differs at its ends, and the middle version is
|
||||
# covered nightly. What it does not do is skip the container lane on a runner without Docker —
|
||||
# PostgreSqlContainerFactory throws, because a skipped contract reports success for a database
|
||||
# nobody tested.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/adapter/outbound/persistence-jpa/**'
|
||||
- 'src/app-bootstrap/src/**/jpa/**'
|
||||
- 'src/config/architecture/modules.json'
|
||||
- 'docs/jpa/**'
|
||||
- 'docs/adr/ADR-JPA-*'
|
||||
- 'infra/jpa/**'
|
||||
- '.github/workflows/jpa-pr.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
jpa-unit-and-architecture:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the JPA unit and architecture suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test
|
||||
:app-bootstrap:test --tests '*CleanArchitectureTest'
|
||||
verifyCleanArchitectureDependencies
|
||||
verifyOneTypePerFile
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-postgresql-contract:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 16 and 18 — the ends of the Stable matrix. 17 runs nightly.
|
||||
postgresql: ["16", "18"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-migration-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the migration upgrade smoke scenarios
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,73 @@
|
||||
name: jpa-release
|
||||
|
||||
# The release gate. Every item in docs/jpa/support-matrix.md's gate table has a job or an assertion
|
||||
# here, and JpaReleaseManifest parses that document so a gate removed from the docs fails the build
|
||||
# rather than quietly ceasing to be checked.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
jpa-release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the full JPA release gate
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
jpaReleaseGate
|
||||
-Pjpa.matrix.versions=16,17,18
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-architecture-and-docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Verify architecture boundaries and the support matrix
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
verifyCleanArchitectureDependencies
|
||||
verifyOneTypePerFile
|
||||
:app-bootstrap:test --tests '*CleanArchitectureTest'
|
||||
:adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,35 @@
|
||||
# ADR-JPA-001 — The domain owns the persistence model
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §10.1, §23.3
|
||||
|
||||
## Context
|
||||
|
||||
A persistence platform can either own the repository abstraction — a `GenericRepository<T, ID>`
|
||||
every aggregate inherits — or provide only the pieces domains assemble themselves.
|
||||
|
||||
## Decision
|
||||
|
||||
The domain owns entities, embeddables, repositories, queries, index requirements, and lock,
|
||||
soft-delete, and audit policy. The platform provides no generic CRUD repository and no base
|
||||
repository. `JpaRepositoryFragmentSupport` exists, has no `save`, `findById`, `findAll`, or
|
||||
`delete`, and is enforced not to acquire them.
|
||||
|
||||
## Consequences
|
||||
|
||||
A generic base repository has one property that looks like a benefit and is not: every aggregate
|
||||
gets the same operations. That means each aggregate is offered operations that may be wrong for it —
|
||||
a `delete` on an append-only ledger, a `findAll` on a table that will never be small — and, worse,
|
||||
one aggregate's later requirement changes the shared base and therefore changes behaviour for
|
||||
aggregates nobody reviewed.
|
||||
|
||||
Spring Data already implements CRUD. Re-implementing it adds a layer whose only function is to be
|
||||
harder to opt out of.
|
||||
|
||||
The cost is a small amount of repetition: each domain declares the repository interface it needs.
|
||||
That repetition is the thing that makes each aggregate's persistence surface reviewable.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaArchitectureRules.noGenericRepository()`; `JpaRepositoryFragmentSupportTest`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-JPA-002 — Retry re-runs the whole use case
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §19.2
|
||||
|
||||
## Context
|
||||
|
||||
Optimistic conflicts, deadlocks, and serialization failures are recoverable. The question is what
|
||||
unit gets retried: the failed statement, the transaction, or the use case.
|
||||
|
||||
## Decision
|
||||
|
||||
The whole use case, in a new transaction with a new Persistence Context.
|
||||
`FullTransactionRetryCoordinator` re-enters `JpaTransactionExecutor` for every attempt, and the
|
||||
retry advice is ordered outside Spring's transaction advice so each attempt begins a new
|
||||
transaction.
|
||||
|
||||
## Consequences
|
||||
|
||||
Statement-level retry is wrong for exactly the failures being retried. An optimistic conflict means
|
||||
the state the attempt computed against is no longer the committed state; re-issuing the same
|
||||
statement computes the same wrong answer against a version that has moved on. The domain rules have
|
||||
to run again over reloaded data, which means the whole use case.
|
||||
|
||||
Reusing the Persistence Context would be equally wrong: the second attempt would read the first
|
||||
attempt's stale entities out of the first-level cache. And with the advice ordering inverted, the
|
||||
retry loop would run inside one transaction that has already been marked rollback-only, so the
|
||||
second attempt fails immediately without executing anything.
|
||||
|
||||
The cost is that a retryable use case must be safe to run from scratch — no irreversible external
|
||||
effect before the commit. `IrreversibleSideEffectContext` lets a use case declare when that does not
|
||||
hold, and the policy then refuses to retry it whatever budget remains.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`FullTransactionRetryCoordinatorTest`; `RetryableJpaTransactionInterceptor.DEFAULT_ORDER`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-JPA-003 — Completion unknown is never retried
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §17
|
||||
|
||||
## Context
|
||||
|
||||
A connection can break while a commit is in flight. The server may have committed; the
|
||||
acknowledgement may simply have been lost. The driver cannot tell the two apart.
|
||||
|
||||
## Decision
|
||||
|
||||
`TransactionCompletionUnknownException` is never retried, automatically or otherwise. It is
|
||||
produced only by a failure observed while the transaction phase is `COMMITTING`, and only for
|
||||
SQLSTATE `40003`, a connection-class (`08*`) state, or a transport break. Recovery is
|
||||
domain-specific reconciliation through `TransactionCompletionResolver`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Retrying a possibly-committed write is the most damaging thing this platform could do: a duplicate
|
||||
payment, a duplicate order, a double decrement. There is no budget or backoff that makes it safe,
|
||||
because the failure is epistemic rather than transient.
|
||||
|
||||
The invariant is enforced at the type level rather than by policy alone. `JpaFailureContext` refuses
|
||||
to construct a retryable completion-unknown context, and the exception rebuilds its context through
|
||||
the safe factory whatever it is handed. A future policy bug therefore cannot produce an unsafe
|
||||
retry — the value it would need does not exist.
|
||||
|
||||
The rule is deliberately narrow in the other direction too. Classifying every connection failure as
|
||||
completion-unknown would push ordinary pool exhaustion and server restarts into the reconciliation
|
||||
queue, which trains operators to clear that queue without reading it — and then the one entry that
|
||||
mattered gets cleared with the rest.
|
||||
|
||||
The cost is that the domain must supply the resolver. The platform cannot: only the domain knows
|
||||
which idempotency record, business row, or outbox entry proves the write happened.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaFailureContextTest`; `DefaultJpaRetryPolicyTest`; `CommitFailureClassifierTest`; release gate
|
||||
`completion-unknown-no-retry`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-JPA-004 — Flyway is the schema source of truth
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §31
|
||||
|
||||
## Context
|
||||
|
||||
Hibernate can create and alter schema from the entity mapping. Flyway can apply versioned scripts.
|
||||
Both cannot own the schema.
|
||||
|
||||
## Decision
|
||||
|
||||
Flyway owns every schema change. Hibernate validates and never mutates: `ddl-auto` is `validate` or
|
||||
`none`, enforced at startup. The runtime database credential holds no DDL privilege, so the rule is
|
||||
enforced by the server as well as by configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
`ddl-auto=update` fails in a specific and expensive way: it adds but never drops or narrows, so the
|
||||
result is a schema that is neither the previous one nor the one the mappings describe — produced
|
||||
silently, by whichever instance started first, with no record of what it did.
|
||||
|
||||
Two credentials rather than one is what makes this more than a convention. A configuration rule can
|
||||
be overridden by a property; a role without `CREATE` cannot be overridden by anything the
|
||||
application does.
|
||||
|
||||
Validation fails closed and never repairs. `repair` rewrites the schema history to match the scripts
|
||||
on disk, which resolves a checksum mismatch by deleting the evidence of which change is missing.
|
||||
|
||||
The cost is that a schema change requires a migration script and a deployment step. That is the
|
||||
intended cost: it makes schema change reviewable and reversible.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaDangerousConfigurationGuard`; `FlywaySchemaPolicy`; `FlywayValidationGate`;
|
||||
`PostgreSqlRuntimeRoleVerifier`; release gates `flyway-validate` and `runtime-role-no-ddl`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR-JPA-005 — Contracts run against real PostgreSQL
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §40
|
||||
|
||||
## Context
|
||||
|
||||
An in-memory database makes tests fast and hermetic. A container makes them slow and requires
|
||||
Docker.
|
||||
|
||||
## Decision
|
||||
|
||||
Every persistence contract runs against real PostgreSQL 16, 17, and 18 in containers. H2 remains a
|
||||
local-development convenience and never satisfies a contract. The lanes fail closed when Docker is
|
||||
absent rather than skipping.
|
||||
|
||||
## Consequences
|
||||
|
||||
The behaviours these contracts verify either do not exist in H2 or differ there: SQLSTATE values for
|
||||
the same violation, `FOR UPDATE SKIP LOCKED` semantics, JSONB operators, range types, concurrent
|
||||
index builds, `search_path` privileges, and the generated SQL for a paged collection fetch. A green
|
||||
H2 run is evidence that the code compiles and runs — not that any of the above holds.
|
||||
|
||||
Three versions rather than one because the platform claims three. A contract suite that ran only on
|
||||
16 would make "Stable on 17 and 18" an assumption.
|
||||
|
||||
Skipping on missing Docker is the failure mode this decision most wants to avoid: a skipped contract
|
||||
reports success, and CI eventually inherits that silence. `PostgreSqlContainerFactory.assertDockerAvailable()`
|
||||
throws instead.
|
||||
|
||||
The cost is that the contract lanes need Docker and take minutes. The unit lane stays hermetic and
|
||||
fast, and is where most tests live; the container lanes verify the things only a real server can
|
||||
answer.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`PostgreSqlVersion.stable()`; `PostgreSqlContainerFactory`; release gate `postgresql-contract`.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Entity Mapping Guide
|
||||
|
||||
Design §10-§13. The rules here exist because each one has a failure mode that is invisible in review
|
||||
and expensive in production.
|
||||
|
||||
## The domain owns the model
|
||||
|
||||
The platform defines no business entity. Table names, column semantics, keys, unique and check
|
||||
requirements, associations, cascade rules, lock policy, and soft-delete policy all belong to the
|
||||
domain module. There is no `GenericRepository<T, ID>` and no platform base repository, because a
|
||||
single generic API forces every aggregate through the same operations — and one aggregate's later
|
||||
requirement then changes behaviour for all of them.
|
||||
|
||||
## Entities must be proxyable
|
||||
|
||||
- Not `final`. Hibernate creates a lazy proxy by generating a subclass; a final entity cannot be
|
||||
subclassed, so *every* association to it loads eagerly whatever the mapping says. Nothing errors.
|
||||
- A non-private no-arg constructor. The provider instantiates entities reflectively before
|
||||
populating fields.
|
||||
|
||||
`EntityMappingCondition` in the testkit enforces both.
|
||||
|
||||
## Identifiers
|
||||
|
||||
Default to a sequence with an `allocationSize` that matches the migration's `INCREMENT BY`. When
|
||||
they disagree, the provider hands out identifiers the sequence has not reserved and the collision
|
||||
surfaces later as a primary-key violation under load.
|
||||
|
||||
`GenerationType.IDENTITY` is supported and limited: the key is assigned on insert, so the provider
|
||||
must execute each insert immediately to learn it, which disables JDBC insert batching entirely.
|
||||
`HibernateBatchConfigurationGuard` fails a batch profile that targets an IDENTITY entity rather than
|
||||
letting the import silently run an order of magnitude slower.
|
||||
|
||||
UUIDv7 (`UuidV7Generator`) is the application-side option. It is preferred over UUIDv4 for a primary
|
||||
key because v4 is uniformly random: every insert lands on a random leaf of the B-tree, so the index
|
||||
never stays in cache and write amplification grows with the table.
|
||||
|
||||
## Values
|
||||
|
||||
- Enums are `EnumType.STRING` or an explicit converter. **Never** `ORDINAL` — it stores the
|
||||
constant's position, so inserting a new constant anywhere but the end silently reinterprets every
|
||||
existing row.
|
||||
- Money is `BigDecimal` with explicit precision and scale. `double` cannot represent `0.1`, so sums
|
||||
drift and reconciliation disagrees with the ledger.
|
||||
- `Duration` goes through a converter that stores milliseconds. The ISO-8601 text form sorts and
|
||||
compares wrongly in SQL.
|
||||
- `Instant` and `OffsetDateTime` map differently; a column typed for one cannot faithfully store the
|
||||
other.
|
||||
|
||||
## Associations
|
||||
|
||||
- To-one associations are `LAZY`. JPA's default is `EAGER`, which means every query that loads a
|
||||
child also queries for its parent — the most common accidental N+1 in a JPA application.
|
||||
- The owning side holds the foreign key. Adding to the inverse collection alone leaves the row
|
||||
unlinked, so aggregates expose an association helper that sets both sides.
|
||||
- `CascadeType.ALL` with `orphanRemoval` is correct only for a child the aggregate genuinely owns.
|
||||
Between independent aggregates it deletes rows another part of the system still owns.
|
||||
|
||||
## Entities never leave the transaction
|
||||
|
||||
A controller must not return an entity, or a collection or `Optional` of one. Response serialisation
|
||||
happens after the transaction closes, so a lazy association touched by the serialiser either throws
|
||||
or — with OSIV on, which this platform forbids — issues a query from the view layer, one per element.
|
||||
`EntityExposureCondition` checks generic type arguments, not just the erased return type.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental Promotion Checklist
|
||||
|
||||
`ExperimentalPromotionGate` evaluates this checklist. Every technical item, then the ADR.
|
||||
|
||||
## Technical evidence
|
||||
|
||||
- [ ] **Compatibility** — the Stable contract suite passes on the experimental target, twice, on two
|
||||
supported patch releases. One passing run is a coincidence.
|
||||
- [ ] **Security** — for tenancy features, cross-tenant read *and* write are both proven impossible,
|
||||
including through native SQL, bulk DML, `getReference`, and the second-level cache. A filter
|
||||
that covers only entity queries covers none of those.
|
||||
- [ ] **Failure** — connection reuse does not leak tenant context; a failover does not silently route
|
||||
a read-after-write to a stale replica; the commit-ambiguity scenarios still behave.
|
||||
- [ ] **Migration** — per-tenant migration is resumable after a partial failure, and rate-limited.
|
||||
With one schema per tenant, a run is N independent migrations and "it failed" is not an answer.
|
||||
- [ ] **Performance** — pool capacity, replica lag under load, and per-tenant memory are measured,
|
||||
not estimated. Database-per-tenant fails as a sum, not as an individual pool.
|
||||
|
||||
## Decision
|
||||
|
||||
- [ ] **Reviewed ADR** — recording what is being promised, the operational burden it carries, and
|
||||
what would cause it to be withdrawn.
|
||||
|
||||
The ADR is not a formality. The technical suites establish that something works; the ADR records
|
||||
that the platform should promise it, which is a different question with a different cost.
|
||||
|
||||
## What does not count as evidence
|
||||
|
||||
- The version being generally available.
|
||||
- The feature working in one environment.
|
||||
- A passing suite that skipped because Docker was unavailable.
|
||||
- A green lane whose assertions were relaxed to make it pass.
|
||||
|
||||
## Outcomes
|
||||
|
||||
| Decision | Meaning |
|
||||
|---|---|
|
||||
| `BLOCKED_TECHNICAL` | at least one suite has not passed |
|
||||
| `BLOCKED_MISSING_ADR` | evidence is complete; no reviewed decision exists |
|
||||
| `ELIGIBLE_FOR_STABLE_REVIEW` | both; Stable review may begin |
|
||||
|
||||
The two blocked states are distinct because they need different work: one needs evidence, the other
|
||||
needs a decision.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental Support Matrix
|
||||
|
||||
Everything here is off unless its `backend.jpa.experimental.*` flag is explicitly true, and none of
|
||||
it is part of the Stable composition.
|
||||
|
||||
| Feature | Flag | State |
|
||||
|---|---|---|
|
||||
| Shared-schema multi-tenancy (column) | `backend.jpa.experimental.multitenancy-column` | Experimental |
|
||||
| PostgreSQL RLS multi-tenancy | `backend.jpa.experimental.multitenancy-rls` | Experimental |
|
||||
| Schema-per-tenant | `backend.jpa.experimental.multitenancy-schema` | Experimental |
|
||||
| Database-per-tenant | `backend.jpa.experimental.multitenancy-database` | Experimental |
|
||||
| Consistency-aware read replica | `backend.jpa.experimental.read-replica` | Experimental |
|
||||
| Jakarta Persistence 4.0 lane | `backend.jpa.experimental.jakarta-persistence-4` | Experimental |
|
||||
| Hibernate ORM 8 lane | `backend.jpa.experimental.hibernate-8` | Experimental |
|
||||
| PostgreSQL 19 lane | `backend.jpa.experimental.postgresql-19` | Experimental |
|
||||
|
||||
Presence on the classpath is not consent. `ExperimentalFeatureGate` fails startup when a module is
|
||||
present and its flag is not set, because an experimental module can arrive transitively and a
|
||||
tenant-isolation feature that switched itself on would be the worst possible default.
|
||||
|
||||
## Known constraints
|
||||
|
||||
- Tenant context is fail-closed. An unbound tenant in a shared-schema deployment means a query with
|
||||
no tenant predicate, which returns every tenant's rows.
|
||||
- A Hibernate filter is not the security boundary. It does not apply to native SQL, bulk DML,
|
||||
`getReference`, or the second-level cache.
|
||||
- RLS requires all three of: `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` (the owner is
|
||||
otherwise exempt from its own policies), and a runtime role without `BYPASSRLS`.
|
||||
- Tenant bindings are transaction-local. A session-local setting survives the connection's return to
|
||||
the pool.
|
||||
- `readOnly=true` never routes to a replica on its own. Read-after-write uses a consistency token or
|
||||
the primary.
|
||||
- Unavailable replica lag evidence means the primary. Absence of evidence is not evidence of
|
||||
freshness.
|
||||
- Per-tenant pools are bounded globally. Fifty tenants with a modest pool each is five hundred
|
||||
connections against a server that permits a hundred.
|
||||
- Tenant ids never become metric tags. Tenant cardinality is unbounded by definition.
|
||||
|
||||
## Lanes never change Stable
|
||||
|
||||
A compatibility lane publishes nothing and changes no Stable contract. If Hibernate 8 generates
|
||||
different SQL for the fetch-pagination gate, that is a finding about Hibernate 8 — the 7.x gate keeps
|
||||
asserting what 7.x must do, because that is what deployments run.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Migration Guide
|
||||
|
||||
Design §31-§32. Flyway owns the schema; Hibernate only validates.
|
||||
|
||||
## Who may change the schema
|
||||
|
||||
| Environment | Mode |
|
||||
|---|---|
|
||||
| local, test, dev | migrate at startup with the migration credential |
|
||||
| staging, prod | deployment-owned migration; the application validates only |
|
||||
|
||||
Migrating from inside the application in production means every instance of a rolling deploy races
|
||||
to apply the same script, and the loser's failure is indistinguishable from a real one.
|
||||
|
||||
`ddl-auto` is `validate` or `none`. Never `update`: it never drops or narrows anything, so it
|
||||
produces a schema that is neither the old one nor the one the migrations describe — silently, on
|
||||
whichever instance started first.
|
||||
|
||||
## Validation fails closed and never repairs
|
||||
|
||||
`FlywayValidationGate` throws `SchemaMismatchException` on a checksum mismatch, a missing migration,
|
||||
or a schema Hibernate disagrees with. It never calls `repair`.
|
||||
|
||||
Repair rewrites the schema history table to match whatever scripts are on disk. That resolves the
|
||||
symptom by deleting the evidence: a checksum mismatch means the deployed script differs from the
|
||||
applied one, and the interesting question is which change is missing from this database. Repair
|
||||
makes that question unaskable. It exists only as an explicit admin operation with an operator, a
|
||||
reason, and an approval (design §8.4).
|
||||
|
||||
Only Flyway's structured error codes reach the exception. Its messages embed the script path and
|
||||
part of the failing statement.
|
||||
|
||||
## Concurrent index builds
|
||||
|
||||
`CREATE INDEX CONCURRENTLY` cannot run inside a transaction block, and Flyway wraps migrations in
|
||||
one by default. The migration therefore needs a companion configuration:
|
||||
|
||||
```conf
|
||||
# V42__order_index.sql.conf
|
||||
executeInTransaction=false
|
||||
```
|
||||
|
||||
`ConcurrentIndexMigrationInspector` fails validation without it, and additionally requires the
|
||||
migration to contain nothing else. A failed concurrent build leaves an invalid index behind;
|
||||
recovering is a single `DROP INDEX` when the migration did nothing else, and a manual reconstruction
|
||||
of partial state when it did.
|
||||
|
||||
An invalid index is not merely useless — the planner ignores it while every write still maintains
|
||||
it. `FailedConcurrentIndexRecovery` reports them with the statement to run, and deliberately does
|
||||
not drop them: an invalid index can also mean a build is still running, and the two are
|
||||
indistinguishable from the catalog alone.
|
||||
|
||||
## Upgrade scenarios
|
||||
|
||||
Three, each catching something the others do not:
|
||||
|
||||
| Scenario | Catches |
|
||||
|---|---|
|
||||
| `empty` | an early migration edited to match a later one, no longer applying to a fresh database |
|
||||
| `previous-release` | the actual deployment path; the only one exercising this release's migrations |
|
||||
| `oldest-supported` | a migration that silently assumes state only recent databases have |
|
||||
|
||||
Each asserts a data invariant, not just the schema version. A migration that renames a column and
|
||||
loses its contents leaves the version correct and the data gone.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Observability
|
||||
|
||||
Design §37. What is measured, and what must never appear in a measurement.
|
||||
|
||||
## Bounded tags, always
|
||||
|
||||
Every JPA metric carries exactly five tags: persistence unit, operation, query, outcome, failure
|
||||
category. All five are registered identifiers, validated by `LowCardinality` at construction rather
|
||||
than at the registry — so an unbounded value fails where it was introduced instead of surviving
|
||||
until a dashboard stops loading.
|
||||
|
||||
Never a tag: entity id, tenant id, SQL parameter, exception message, JDBC URL. Each is unbounded, so
|
||||
each creates a time series per row or per failure; several are also the data the platform keeps out
|
||||
of logs, which a metrics backend would store just as durably and export just as widely.
|
||||
|
||||
## Transaction metrics
|
||||
|
||||
| Meter | Why it exists |
|
||||
|---|---|
|
||||
| `jpa.transaction.duration` | the baseline |
|
||||
| `jpa.transaction.rollback` | rollback rate by failure category |
|
||||
| `jpa.transaction.timeout` | timeouts, distinct from other rollbacks |
|
||||
| `jpa.transaction.completion.unknown` | its own counter, deliberately |
|
||||
|
||||
Completion-unknown gets a separate counter rather than being folded into failures. It is the one
|
||||
outcome that means a human has to look: every other failure is a transaction that definitely did not
|
||||
happen, while this one is a transaction that may have.
|
||||
|
||||
## Query metrics
|
||||
|
||||
`jpa.query.duration` and `jpa.query.rows`. Rows are measured as well as duration because a query
|
||||
that issues one statement and hydrates twenty thousand rows is fast per statement and catastrophic
|
||||
per request — a duration metric alone reports it as merely slow.
|
||||
|
||||
## Retry metrics
|
||||
|
||||
Attempts are metrics, not warnings. Optimistic conflicts and serialization failures are the expected
|
||||
cost of concurrency; logging each at WARN pages someone for a system working as designed, after
|
||||
which the retry log gets filtered out and takes the genuinely interesting entries with it.
|
||||
|
||||
`jpa.retry.attempt`, `jpa.retry.attempts` (distribution per operation), `jpa.retry.exhausted`.
|
||||
|
||||
## Query names in SQL
|
||||
|
||||
`NamedStatementInspector` prefixes each statement with its registered query name as a SQL comment,
|
||||
which travels into `pg_stat_activity`, `auto_explain`, and the slow-query log. Without it, "which
|
||||
endpoint issues this query" is answered by grepping the codebase for fragments of SQL.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
`SqlDiagnosticRedactor` removes string literals, numbers, and anything email-shaped before SQL
|
||||
reaches a log. Redaction is blunt on purpose: preserving "harmless" values would require knowing
|
||||
which columns hold personal data.
|
||||
|
||||
## The actuator endpoint
|
||||
|
||||
`jpaplatform` reports database major version, provider version, schema version, OSIV state, runtime
|
||||
role verification, and capability levels. It reports no JDBC URL, no username, no SQL, and no entity
|
||||
catalog — an actuator endpoint is reachable by anyone who reaches the management port, and each of
|
||||
those would be a free reconnaissance answer. It is read-only: an endpoint that could trigger a
|
||||
migration or a repair would be an admin capability exposed over HTTP.
|
||||
@@ -0,0 +1,72 @@
|
||||
# PostgreSQL Extensions
|
||||
|
||||
Design §8.3, §21, §30. What the platform uses beyond portable JPA, and what each is guarded by.
|
||||
|
||||
Everything here is core PostgreSQL. No server extension is required.
|
||||
|
||||
## Locking
|
||||
|
||||
`SELECT ... FOR UPDATE` with a finite bound, always. `PostgreSqlLockOptions` refuses an unbounded
|
||||
lock request because it waits as long as the holder holds it, turning one slow transaction into a
|
||||
pile-up of blocked connections.
|
||||
|
||||
`NOWAIT` and a wait timeout are separate requests, not two spellings of one — modelling them as a
|
||||
single field with a magic zero is how "no wait" becomes "wait forever".
|
||||
|
||||
`55P03` (lock not available) and `40P01` (deadlock) drive opposite recovery and are never collapsed:
|
||||
the first leaves the transaction alive and the caller in control; the second has already been rolled
|
||||
back by the server.
|
||||
|
||||
## Work claims
|
||||
|
||||
`FOR UPDATE SKIP LOCKED` is reachable only through a registered `WorkQueueName`, never as a
|
||||
repository flag. It deliberately returns an incomplete view of the table: correct for handing
|
||||
disjoint work to competing workers, silently wrong for anything that needs to see every matching
|
||||
row. A registered claim statement must skip locked rows and impose a deterministic `ORDER BY`.
|
||||
|
||||
## Upserts
|
||||
|
||||
`INSERT ... ON CONFLICT ... RETURNING` under a registered `NativeWriteName` with a fixed conflict
|
||||
target and update column set. The conflict target cannot be a bound parameter, so accepting one from
|
||||
a caller would mean building SQL from input.
|
||||
|
||||
An upsert is the correct answer to a create race precisely because the database decides.
|
||||
Read-then-write cannot be made correct: another transaction can commit between the read and the
|
||||
write. `(xmax = 0) AS inserted` in the `RETURNING` list is what lets the platform report
|
||||
insert-versus-update without a second query.
|
||||
|
||||
The executor flushes before and clears after: a native write is invisible to the Persistence
|
||||
Context, so a pending managed change would otherwise overwrite it, and a managed entity loaded
|
||||
beforehand would keep serving pre-upsert values.
|
||||
|
||||
## JSONB
|
||||
|
||||
`JsonDocument` carries a schema name and version alongside the payload. A JSONB column is schemaless
|
||||
at the database level, so without an envelope the only record of what a stored document means is the
|
||||
code that wrote it — and a document written two releases ago is indistinguishable from a current one.
|
||||
|
||||
The payload never carries a Java class name. Type metadata in a JSONB column is a deserialization
|
||||
gadget: whoever can write a row chooses the class the reader instantiates.
|
||||
|
||||
Query paths are registered. A JSON path is part of the SQL text and cannot be bound, so forwarding a
|
||||
request field into one is concatenating untrusted input into a statement. Values are always bound.
|
||||
|
||||
## Arrays and ranges
|
||||
|
||||
Arrays are built with `Connection.createArrayOf`, never by formatting a literal — hand-formatting is
|
||||
where quoting bugs live, and a tag containing a comma changes the array's shape rather than its
|
||||
content.
|
||||
|
||||
`PgRange` models both endpoints as independently optional and independently inclusive, because that
|
||||
is what a PostgreSQL range is. Whether `[09:00, 10:00)` and `[10:00, 11:00)` overlap depends on the
|
||||
bracket, not the values, and a pair of `timestamptz` columns cannot express it.
|
||||
|
||||
## COPY (J4 admin)
|
||||
|
||||
`COPY` bypasses the Persistence Context, entity callbacks, version checks, and Envers entirely. That
|
||||
is why it is fast and why it is an admin capability with a registered statement, a bounded stream, a
|
||||
row and byte cap, a finite server-side `statement_timeout`, and a named operator.
|
||||
|
||||
The registry accepts only `COPY ... FROM STDIN`. `COPY ... FROM '/path'` reads a file on the
|
||||
*database server* as the server's OS user; it is superuser-only for exactly that reason and does not
|
||||
belong behind an application API.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Query and Fetch Guide
|
||||
|
||||
Design §23-§28. How queries are chosen, bounded, and proven.
|
||||
|
||||
## Named queries
|
||||
|
||||
Every registered query carries a `QueryName`. It becomes the metric tag, the trace attribute, and
|
||||
the SQL comment that appears in `pg_stat_activity` and the slow-query log — which is the only thing
|
||||
that connects a statement on the server back to the use case that issued it. The format rejects raw
|
||||
SQL for a reason: a metric tag built from a query string is unbounded by construction, and one built
|
||||
from a parameterised value leaks row data into telemetry.
|
||||
|
||||
## Fetch plans, not eager mappings
|
||||
|
||||
N+1 is solved per use case with a registered entity graph, not by making an association `EAGER` in
|
||||
the mapping. The eager fix repairs the one query that needed it and imposes the extra join on every
|
||||
other query against that entity, including the ones that only wanted the id.
|
||||
|
||||
`fetchgraph` and `loadgraph` are different: a fetch graph is exhaustive (attributes outside it are
|
||||
lazy whatever the mapping says), a load graph is additive. Choosing the wrong one produces either
|
||||
missing data or the amplification the graph was meant to avoid.
|
||||
|
||||
## Measuring, not guessing
|
||||
|
||||
`QueryMeasurement` records statements, hydrated entities, rows, fetches, and elapsed time. Statement
|
||||
count alone cannot distinguish the two failures that matter:
|
||||
|
||||
- **N+1** — many statements, few rows.
|
||||
- **Cartesian fetch** — one statement, an enormous number of rows.
|
||||
|
||||
A suite asserting only on statement count passes the second one every time.
|
||||
|
||||
## Pagination
|
||||
|
||||
Offset pagination makes the database walk and discard `n` rows before returning any. Keyset
|
||||
pagination replaces it:
|
||||
|
||||
- The predicate is lexicographic. For an ordering of `(createdAt, id)`, "after `(t, x)`" is
|
||||
`createdAt < t OR (createdAt = t AND id < x)` — **not** `createdAt <= t AND id < x`, which reads
|
||||
plausibly and silently drops rows from the middle of the result set.
|
||||
- The ordering must end in a unique column. Without one, a page boundary inside a run of equal
|
||||
values duplicates and skips rows.
|
||||
- `size + 1` rows are fetched and `size` returned. That extra row answers `hasNext` without a count
|
||||
query, which would be a second full scan whose answer is stale on arrival.
|
||||
|
||||
Cursors are signed. An unsigned cursor is client-controlled ordering state: rewriting it lets a
|
||||
caller seek to arbitrary keys.
|
||||
|
||||
## Sorting
|
||||
|
||||
Client sort parameters are mapped through `SafeSortRegistry`, never passed through. A sort field
|
||||
reaches the query as part of the ORDER BY clause rather than as a bound value, so forwarding the
|
||||
client's string means the client writes part of the statement. `JpaSort.unsafe` has no call site in
|
||||
this platform.
|
||||
|
||||
The registry's tie-breaker is always appended, because a sort that does not end in a unique column
|
||||
has no total order and paging over a non-total order duplicates and skips rows.
|
||||
|
||||
## Streaming
|
||||
|
||||
A JPA `Stream` is a live cursor holding a `ResultSet`, a statement, and a connection. `JpaStreamExecutor`
|
||||
consumes it inside a try-with-resources and never returns it, because a stream returned past the
|
||||
transaction boundary is a connection leak that presents as unrelated timeouts elsewhere. A read-only
|
||||
transaction is required: streaming inside a write transaction pins a write connection for the whole
|
||||
traversal.
|
||||
|
||||
## Batching
|
||||
|
||||
Configuring `hibernate.jdbc.batch_size` proves nothing. `BatchExecutionResult.jdbcBatches` comes from
|
||||
counting real `executeBatch()` calls at the JDBC layer, because an IDENTITY generator, an interleaved
|
||||
select, or a mid-loop flush disables batching while the configuration still says it is on.
|
||||
|
||||
Flush and clear are separate boundaries. Flushing alone sends the statements and keeps every entity
|
||||
in the Persistence Context — the classic bulk-import out-of-memory.
|
||||
@@ -0,0 +1,156 @@
|
||||
# JPA Relational Persistence Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
|
||||
(copied to `docs/superpowers/specs/`)
|
||||
**Stable plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
|
||||
(copied to `docs/superpowers/plans/`)
|
||||
**Experimental plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
|
||||
(copied to `docs/superpowers/plans/`)
|
||||
|
||||
The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle
|
||||
structure are explicit implementation *assumptions* made because the real Backend Skeleton
|
||||
repository was not supplied. Before implementing, paths are adjusted to the repository's existing
|
||||
conventions and root package while the public contracts and policy semantics are preserved.
|
||||
|
||||
This file is the single record of *how* that mapping was performed. Only paths, build DSL, and
|
||||
composition-root ownership changed. Public contracts, policy order, retry semantics, and error
|
||||
semantics are implemented as specified.
|
||||
|
||||
## 1. Why the module layout differs
|
||||
|
||||
The plan assumes a greenfield library with 18 Stable Gradle projects under `modules/jpa/` plus 7
|
||||
Experimental projects under `modules/jpa-experimental/`. This repository is a Clean Architecture
|
||||
template whose **fail-closed registry** (`src/config/architecture/modules.json`, enforced by
|
||||
`src/settings.gradle` and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf
|
||||
identities**, and `src/settings.gradle` throws when the registry does not contain exactly 19
|
||||
modules. Creating 25 more Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||
|
||||
Therefore the plan's library modules become **package boundaries inside the registered leaf**
|
||||
`:adapter:outbound:persistence-jpa`, with two exceptions driven by this repository's own rules.
|
||||
This is the same adaptation already applied to the HTTP client platform
|
||||
(`docs/httpclient/repository-adaptation.md`).
|
||||
|
||||
| Plan module | Repository home | Reason |
|
||||
|---|---|---|
|
||||
| `jpa-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.jpa`) | This repository's composition root owns wiring, startup validation, and actuator surface; an adapter leaf must not auto-configure itself. `AGENTS.md` assigns composition to `app-bootstrap`. |
|
||||
| `jpa-testkit`, `jpa-testkit-postgresql`, `jpa-testkit-migration`, `jpa-testkit-queryplan` | `:adapter:outbound:persistence-jpa` `src/testkit/java/**/testkit` | The plan forbids production modules depending on the testkit. A source set whose dependencies are declared only on test configurations gives the same guarantee without a new Gradle project, and more than one lane consumes it. |
|
||||
|
||||
The package boundary is enforced by `JpaModuleBoundaryTest`, which reproduces the plan's
|
||||
§3 module dependency map as package rules.
|
||||
|
||||
## 2. Package mapping
|
||||
|
||||
Root package: `io.backend.skeleton.jpa` → `dev.caskeleton.adapter.outbound.persistence`.
|
||||
|
||||
| Plan module | Plan package | Repository package |
|
||||
|---|---|---|
|
||||
| `jpa-core-api` | `…jpa.api` (+ `.capability`, `.error`, `.query`, `.transaction`) | `dev.caskeleton.adapter.outbound.persistence.api` (+ same subpackages) |
|
||||
| `jpa-transaction` | `…jpa.transaction` | `…persistence.transaction` |
|
||||
| `jpa-spring-data` | `…jpa.springdata` | `…persistence.springdata` |
|
||||
| `jpa-querydsl` | `…jpa.querydsl` | `…persistence.querydsl` |
|
||||
| `jpa-hibernate` | `…jpa.hibernate` (+ `.batch`, `.bulk`, `.stateless`) | `…persistence.hibernate` (+ same subpackages) |
|
||||
| `jpa-postgresql` | `…jpa.postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`) | `…persistence.postgresql` (+ same subpackages) |
|
||||
| `jpa-postgresql-copy` | `…jpa.postgresql.copy` | `…persistence.postgresql.copy` |
|
||||
| `jpa-migration-flyway` | `…jpa.migration` | `…persistence.migration` |
|
||||
| `jpa-auditing` | `…jpa.auditing` | `…persistence.auditing` |
|
||||
| `jpa-envers` | `…jpa.envers` | `…persistence.envers` |
|
||||
| `jpa-cache-hibernate` | `…jpa.cache` | `…persistence.cache` |
|
||||
| `jpa-observability` | `…jpa.observation` | `…persistence.observation` |
|
||||
| `jpa-security` | `…jpa.security` | `…persistence.security` |
|
||||
| `jpa-spring-boot-starter` | `…jpa.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.jpa` |
|
||||
| `jpa-testkit*` | `…jpa.testkit` (+ `.id`, `.mapping`, `.lifecycle`, `.query`, `.fetch`, `.postgresql`, `.migration`, `.queryplan`, `.failure`, `.pool`, `.release`) | `…persistence.testkit` (+ same subpackages), `testkit` source set |
|
||||
| `jpa-experimental/*` | `…jpa.experimental` (+ `.tenant`, `.rls`, `.schema`, `.database`, `.replica`, `.next`) | `…persistence.experimental` (+ same subpackages) |
|
||||
|
||||
The existing `…persistence.transaction` and `…persistence.postgresql` packages already hold this
|
||||
leaf's `TransactionPort` implementation and PostgreSQL vendor composition. The platform types are
|
||||
**additive**: no existing type was renamed, moved, or replaced, and no plan type collides with an
|
||||
existing name.
|
||||
|
||||
## 3. Test-suite mapping
|
||||
|
||||
The plan declares seven JVM test suites (`test`, `integrationTest`, `contractTest`,
|
||||
`migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest`). This leaf already owns a
|
||||
Docker-backed `postgresqlIntegrationTest` source set and its readiness Gradle tasks are registered
|
||||
in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
|
||||
|
||||
| Plan suite | Repository lane |
|
||||
|---|---|
|
||||
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
|
||||
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
|
||||
| `performanceTest` | `src/jpaPlatformPerformanceTest` — machine-dependent bounds, never part of `check` |
|
||||
|
||||
Docker-dependent lanes fail closed rather than skipping, matching the existing
|
||||
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
|
||||
|
||||
## 4. Other deliberate substitutions
|
||||
|
||||
| Plan assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
|
||||
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
|
||||
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
|
||||
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
|
||||
| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. |
|
||||
| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||
| `build.gradle.kts` release aggregate `jpaReleaseGate` | Root is `src/build.gradle` | Registered there against the repository lane names in §3. |
|
||||
| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. |
|
||||
| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". |
|
||||
| `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` | There is no `build-logic` project and no Kotlin source set; module boundaries are enforced by the registry itself | `verifyCleanArchitectureDependencies` plus `:app-bootstrap:test --tests '*CleanArchitectureTest'` assert the same property against `src/config/architecture/modules.json`, which is the authority the plan's test would have had to duplicate. |
|
||||
| `PostgreSqlRuntimeRoleVerifierIntegrationTest` (Task 45) | The security lane is one suite in this leaf rather than a per-module `integrationTest` | `PostgreSqlSecurityContractTest` (tag `jpa-security`) exercises `PostgreSqlRuntimeRoleVerifier.verify` and `.requireSafe` against a real restricted role on a real server. |
|
||||
| `JpaSafetyProperties`, `JpaDataSourceProperties` | `NamingConventionTest` requires every `@ConfigurationProperties` type to end in `Settings` or `Policy` | Renamed to `JpaSafetySettings` and `JpaDataSourceSettings`. The bound property prefixes and every field are unchanged; only the class names move to this repository's convention. |
|
||||
|
||||
### Types relocated to keep the dependency direction legal
|
||||
|
||||
The plan's module map forbids `jpa-core-api` from depending on any other platform module. Three
|
||||
value-only types the design places in a downstream module are consumed by a core contract, so they
|
||||
live in the core here instead. Each is a pure value with no framework dependency, so the relocation
|
||||
costs nothing and the alternative — a core contract importing an adapter package — would break the
|
||||
boundary the module map exists to hold.
|
||||
|
||||
| Type | Plan module | Repository package | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `TransactionCompletionEvidence` | `jpa-transaction` | `…persistence.api.transaction` | `TransactionCompletionUnknownException` (design §17.3 types the field) |
|
||||
| `ConstraintCode` | `jpa-postgresql` | `…persistence.api.error` | `ConstraintViolationDetails` (design §22.4) |
|
||||
| `SqlStateResolver`, `SqlExceptionSqlStateResolver` | `jpa-transaction` | `…persistence.api.error` | both the transaction module's commit classifier and the PostgreSQL translator |
|
||||
|
||||
The ArchUnit rule pack (`JpaArchitectureRules`, `EntityMappingCondition`, `EntityExposureCondition`)
|
||||
is placed in the `testkit` source set rather than in `…persistence.security` production code. ArchUnit
|
||||
is a test library; putting the rule pack in `main` would drag it onto every deployment's runtime
|
||||
classpath to serve code that only ever runs in a test.
|
||||
|
||||
|
||||
### Findings the contracts produced against a real server
|
||||
|
||||
Two of the design's rules turned out to be stated slightly wrong, and the container lanes are what
|
||||
showed it. Both are recorded here because the design text still reads the old way.
|
||||
|
||||
- **§17.2 commit ambiguity is not only SQLSTATE class `08`.** `pg_terminate_backend` on a backend
|
||||
with a commit in flight reports `57P01` (`admin_shutdown`), not a connection-class state — and the
|
||||
commit record may already be in the WAL when it arrives. `CommitFailureClassifier` now treats
|
||||
`57P01`/`57P02`/`57P03` as completion-unknown alongside `40003`, class `08`, and transport breaks.
|
||||
`CommitAmbiguityContractTest` asserts the SQLSTATE directly so the rule cannot silently narrow
|
||||
again.
|
||||
- **Schema-per-tenant status must be read back, not inferred from the run.** `MigrateResult`'s
|
||||
target version is empty for a tenant that was already current, so recording it reported migrated
|
||||
tenants as unmigrated during a partial rollout. `SchemaTenantMigrationOrchestrator` now reads the
|
||||
applied version from the tenant's schema history.
|
||||
|
||||
## 5. What is unchanged from the design
|
||||
|
||||
- Domain owns Entity, Embeddable, Repository, Query, index requirements, lock/soft-delete/audit
|
||||
policy. No `GenericRepository<T, ID>` and no Spring Data CRUD re-implementation exists.
|
||||
- Application Service owns the transaction boundary; OSIV is false in every runtime profile.
|
||||
- `TransactionCompletionUnknownException` always reports `completionUnknown=true`,
|
||||
`retryable=false`, and is never automatically retried — reconciliation handles it.
|
||||
- Retry re-executes the whole use case in a new transaction and a new Persistence Context.
|
||||
- SQLSTATE classification is structural (`40001`, `40003`, `40P01`, `23505`, `23503`, `23514`,
|
||||
`55P03`) and never parses localized message text.
|
||||
- Flyway is the source of truth for production schema change; Hibernate only validates;
|
||||
`ddl-auto` never mutates a deployed schema.
|
||||
- `CREATE INDEX CONCURRENTLY` requires an explicit non-transactional migration marker.
|
||||
- Metric labels and ordinary logs never carry SQL parameters, entity IDs, tenant IDs, or PII.
|
||||
- Experimental features (multi-tenancy, RLS, schema/database tenancy, read replica, JPA 4,
|
||||
Hibernate 8, PostgreSQL 19) stay behind `backend.jpa.experimental.*` flags and never enter the
|
||||
Stable composition.
|
||||
@@ -0,0 +1,85 @@
|
||||
# JPA Platform Runbooks
|
||||
|
||||
Operator procedures for the failures this platform is designed to surface rather than hide.
|
||||
|
||||
## A transaction reported completion unknown
|
||||
|
||||
**Signal:** `jpa.transaction.completion.unknown` incremented; a `CompletionUnknownRecord` in the
|
||||
reconciliation channel.
|
||||
|
||||
**What it means:** the commit may or may not have happened. It is not a rollback.
|
||||
|
||||
**Do not** re-run the use case. That is what the platform refused to do automatically, for the same
|
||||
reason.
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. Take the `transactionKey` from the record.
|
||||
2. Check the idempotency record for that key.
|
||||
3. Check the business row the use case would have written.
|
||||
4. Check the outbox for a corresponding event.
|
||||
5. If all three agree the write happened, mark the record `COMMITTED` and stop.
|
||||
6. If all three agree it did not, the use case may be re-run.
|
||||
7. If they disagree or are inconclusive, leave it `STILL_UNKNOWN` and escalate. An inconclusive
|
||||
answer is a legitimate outcome; guessing is not.
|
||||
|
||||
A record with no `transactionKey` cannot be resolved automatically — use the operation name and
|
||||
timestamp.
|
||||
|
||||
## Deadlock or serialization rate rising
|
||||
|
||||
**Signal:** `jpa.retry.attempt` rising; `jpa.retry.exhausted` non-zero.
|
||||
|
||||
Retries are expected. Exhaustion is not.
|
||||
|
||||
1. Group `jpa.retry.attempt` by operation. A single operation dominating means a hot row or an
|
||||
inconsistent lock order.
|
||||
2. For deadlocks, check whether two operations take the same rows in opposite orders — that is a
|
||||
code fix, not a tuning one.
|
||||
3. For serialization failures under `SERIALIZABLE`, confirm the isolation is actually required.
|
||||
4. Only then consider raising `maxAttempts`. A larger budget on a hot row converts a fast failure
|
||||
into a slow one.
|
||||
|
||||
## Pool exhaustion
|
||||
|
||||
**Signal:** connection acquisition timeouts; `PoolMeasurement.pending` non-zero.
|
||||
|
||||
1. Check `REQUIRES_NEW` usage. It takes a second connection while pinning the first, so the pool
|
||||
must satisfy `(threads x (1 + depth)) + 1`.
|
||||
2. Check for streaming outside a bounded scope — a `Stream` returned past the transaction holds its
|
||||
connection until the pool notices.
|
||||
3. Check for external calls inside a DB transaction. The design forbids them precisely because an
|
||||
HTTP timeout then holds a connection for its whole duration.
|
||||
|
||||
## Flyway validation failed at startup
|
||||
|
||||
The deployment is running against a schema it was not built for. It failed closed, which is correct.
|
||||
|
||||
1. Read the reported error codes (the messages are deliberately not propagated).
|
||||
2. `CHECKSUM_MISMATCH` — an applied migration was edited afterwards. Find which change is missing
|
||||
from this database. **Do not run `repair`**: it rewrites history to match the scripts, which
|
||||
resolves the symptom by deleting the evidence.
|
||||
3. `MISSING_SCRIPT` — a migration applied here is not in this build. Usually a rollback to an older
|
||||
artifact.
|
||||
|
||||
## An invalid index exists
|
||||
|
||||
**Signal:** `FailedConcurrentIndexRecovery.invalidIndexes()` is non-empty.
|
||||
|
||||
A concurrent build failed. The index is ignored by the planner and maintained by every write.
|
||||
|
||||
1. Confirm no build is currently running. An in-progress build looks identical in the catalog.
|
||||
2. Run the reported `DROP INDEX CONCURRENTLY` outside a migration.
|
||||
3. Re-apply the index migration.
|
||||
|
||||
The platform does not drop these automatically: on a rolling deploy every instance would race to
|
||||
drop an index another instance was about to finish building.
|
||||
|
||||
## The runtime role failed verification
|
||||
|
||||
Startup refused because the runtime credential holds `CREATE`, or `search_path` contains an
|
||||
unapproved schema.
|
||||
|
||||
This is not a false positive to be worked around. Re-provision from
|
||||
`infra/jpa/roles/runtime-roles.sql`; the application's credential having DDL is the condition that
|
||||
makes every other schema guarantee unenforceable.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Security
|
||||
|
||||
Design §36. Credential separation, privilege verification, and what never leaves the process.
|
||||
|
||||
## Three credentials
|
||||
|
||||
| Role | May |
|
||||
|---|---|
|
||||
| `app_migration` | own the schema, apply migrations (DDL) |
|
||||
| `app_runtime` | select, insert, update, delete (DML only) |
|
||||
| `app_admin` | J4 operations — COPY, backfill, maintenance |
|
||||
|
||||
The separation is what makes "Flyway owns schema change" enforceable rather than aspirational. If
|
||||
the application's own credential cannot execute DDL, then no code path, no library, and no injected
|
||||
statement can alter the schema at runtime, regardless of what the application intended.
|
||||
|
||||
`infra/jpa/roles/runtime-roles.sql` provisions them.
|
||||
|
||||
## Startup verification
|
||||
|
||||
`PostgreSqlRuntimeRoleVerifier` asks the *server* what the connection can do:
|
||||
|
||||
```sql
|
||||
select current_user,
|
||||
current_setting('search_path'),
|
||||
has_schema_privilege(current_user, current_schema(), 'CREATE'),
|
||||
has_database_privilege(current_user, current_database(), 'CREATE')
|
||||
```
|
||||
|
||||
Configuration cannot answer this. Effective privileges come from direct grants, inherited role
|
||||
memberships, `PUBLIC` grants, and schema ownership, and no reading of a deployment manifest
|
||||
reconstructs that combination reliably.
|
||||
|
||||
Startup fails when the runtime role is not on the allowlist, or holds `CREATE` on the schema or the
|
||||
database.
|
||||
|
||||
## search_path
|
||||
|
||||
`SearchPathPolicy` is an allowlist. `search_path` decides which schema an unqualified name resolves
|
||||
to, so a writable untrusted schema on it — classically `public`, where `CREATE` was granted broadly
|
||||
before PostgreSQL 15 — lets a planted table, function, or operator shadow the real one, and the
|
||||
application executes it without noticing. `$user` is exempt: only the connected role owns it.
|
||||
|
||||
Refusing the runtime role `CREATE` closes the same route from the other side.
|
||||
|
||||
## What never leaves the process
|
||||
|
||||
- SQL parameter values, entity ids, tenant ids, and PII: not in exception messages, not in metric
|
||||
tags, not in logs. `JpaFailureContext` composes messages from bounded values only.
|
||||
- Constraint names reach the application as registered `ConstraintCode`s; an unregistered physical
|
||||
name maps to a bounded unknown code rather than being passed through.
|
||||
- Cursors are HMAC-signed. An unsigned cursor is client-controlled ordering state.
|
||||
- The actuator report carries no JDBC URL, username, or SQL.
|
||||
|
||||
## Injection surfaces, and how each is closed
|
||||
|
||||
| Surface | Why it cannot be a parameter | Closed by |
|
||||
|---|---|---|
|
||||
| sort field | part of ORDER BY | `SafeSortRegistry` allowlist |
|
||||
| JSON path | part of the statement | registered `JsonPathName` |
|
||||
| schema name | an identifier | registered `SchemaTenantRegistry` |
|
||||
| upsert conflict target | an identifier list | registered `UpsertConflictTarget` |
|
||||
| COPY table | an identifier | registered `RegisteredCopyStatement` |
|
||||
| queue claim SQL | a whole statement | registered `WorkQueueDefinition` |
|
||||
|
||||
Values are always bound. Identifiers are always registered.
|
||||
@@ -0,0 +1,79 @@
|
||||
# JPA Persistence Platform — Support Matrix
|
||||
|
||||
The machine-readable source for `JpaReleaseManifest`. A release gate parses this file, so a version
|
||||
or gate that stops being named here stops being claimed — and the build fails rather than the
|
||||
document quietly drifting from the code.
|
||||
|
||||
## Database
|
||||
|
||||
| Database | Support | Evidence |
|
||||
|---|---|---|
|
||||
| PostgreSQL 16 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 17 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 18 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 19 | Experimental | compatibility lane only; promotion requires an ADR |
|
||||
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
|
||||
|
||||
H2 is not a second production target. It reports different SQLSTATEs for the same violation, has no
|
||||
`SKIP LOCKED` guarantee the platform relies on, no JSONB operators, no range types, and no
|
||||
concurrent index builds. A green H2 run is evidence that the code compiles and runs, and nothing
|
||||
more.
|
||||
|
||||
## Specification and provider
|
||||
|
||||
| Component | Stable | Experimental |
|
||||
|---|---|---|
|
||||
| Jakarta Persistence | 3.2 | 4.0 (lane) |
|
||||
| Hibernate ORM | 7.4 declared baseline | 8 (lane) |
|
||||
| Spring Boot | repository BOM | — |
|
||||
|
||||
The Hibernate row needs a note. The design declares 7.4 as the Stable provider; this repository's
|
||||
Spring Boot BOM resolves 7.1.x. `HibernateProviderPolicy` holds both — the declared baseline as a
|
||||
constant, the resolved version read from Hibernate itself — and `driftsFromDeclaredBaseline()` makes
|
||||
the difference visible instead of asserting a constant against itself. See
|
||||
[repository-adaptation.md](repository-adaptation.md) §4.
|
||||
|
||||
## Capability support levels
|
||||
|
||||
| Capability | Level |
|
||||
|---|---|
|
||||
| Full-transaction retry | Stable |
|
||||
| Commit completion evidence | Stable |
|
||||
| Keyset pagination | Stable |
|
||||
| JDBC batch | Stable |
|
||||
| Flyway schema gate | Stable |
|
||||
| Runtime role verification | Stable |
|
||||
| Observability | Stable |
|
||||
| PostgreSQL native write (`ON CONFLICT`/`RETURNING`) | Advanced |
|
||||
| PostgreSQL work claim (`SKIP LOCKED`) | Advanced |
|
||||
| PostgreSQL JSONB | Advanced |
|
||||
| PostgreSQL array and range | Advanced |
|
||||
| Bulk DML | Advanced |
|
||||
| Hibernate `StatelessSession` | Advanced |
|
||||
| PostgreSQL `COPY` | Admin (J4) |
|
||||
| Hibernate second-level cache | Advanced |
|
||||
| Hibernate Envers | Advanced |
|
||||
| Multi-tenancy (column, RLS, schema, database) | Experimental |
|
||||
| Consistency-aware read replica | Experimental |
|
||||
|
||||
## Release gates
|
||||
|
||||
Each row is a way the platform could pass its tests and still be wrong in production.
|
||||
|
||||
| Gate | Kind | What it prevents |
|
||||
|---|---|---|
|
||||
| `postgresql-contract` | gate | a release whose only database evidence came from H2 |
|
||||
| `completion-unknown-no-retry` | gate | automatically re-running a write that may already have committed |
|
||||
| `osiv-disabled` | gate | lazy loading from the view layer, one query per rendered row |
|
||||
| `flyway-validate` | gate | Hibernate mutating a deployed schema, or running against one it was not built for |
|
||||
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
|
||||
| `hibernate-7.4-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory |
|
||||
|
||||
## Explicitly unsupported
|
||||
|
||||
- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call
|
||||
onto an event loop rather than removing it.
|
||||
- Hibernate as the production schema writer. `ddl-auto` never mutates a deployed schema.
|
||||
- A platform-owned generic CRUD repository. Domains own their repositories (design §10.1).
|
||||
- Automatic reconciliation of a completion-unknown transaction. The platform records; the domain
|
||||
resolves.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Transaction Guide
|
||||
|
||||
Design §15-§20. What owns a transaction, what may be retried, and what must never be.
|
||||
|
||||
## The application service owns the boundary
|
||||
|
||||
Repository adapters do not open transactions. The use case does, through `TransactionPort` or
|
||||
`JpaTransactionExecutor`, because the unit of work is a business decision and only the use case
|
||||
knows where it starts and ends.
|
||||
|
||||
Open Session In View is off in every runtime profile. It is on by default in Spring Boot, which is
|
||||
why `JpaDangerousConfigurationGuard` fails startup rather than trusting configuration review.
|
||||
|
||||
## Profiles
|
||||
|
||||
A `TransactionProfile` fixes propagation, isolation, timeout, read-only, and the retry budget. A
|
||||
write profile must carry a positive finite timeout — the type refuses to represent one without —
|
||||
because an unbounded write transaction holds a connection, its locks, and its row versions for as
|
||||
long as one stuck statement takes.
|
||||
|
||||
`REQUIRES_NEW` is opt-in. 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:
|
||||
|
||||
```text
|
||||
maximumPoolSize >= (concurrent_threads x (1 + max_requires_new_depth)) + 1
|
||||
```
|
||||
|
||||
## Retry is per use case, never per statement
|
||||
|
||||
`FullTransactionRetryCoordinator` re-enters the executor, which produces a new transaction and a new
|
||||
Persistence Context for every attempt. That granularity is the whole point: an optimistic conflict
|
||||
means the state the attempt computed against is no longer the committed state, so re-issuing the
|
||||
same statement would compute the same wrong answer. The domain rules have to run again against
|
||||
reloaded data.
|
||||
|
||||
Retryable: serialization failure (`40001`), deadlock (`40P01`), optimistic conflict.
|
||||
Not retryable: constraint violations, schema mismatch, query timeout, and anything unclassified.
|
||||
|
||||
Two additional refusals, independent of budget:
|
||||
|
||||
- An attempt that declared an irreversible external effect through `IrreversibleSideEffectContext`.
|
||||
Rollback reverses database work only; an email or a card charge has already changed the world.
|
||||
- Anything completion-unknown.
|
||||
|
||||
## Completion unknown
|
||||
|
||||
`TransactionCompletionUnknownException` is never retried, and the type system enforces it twice:
|
||||
`JpaFailureContext` refuses to represent a retryable completion-unknown failure, and the exception
|
||||
rebuilds its context through the safe factory whatever it is handed.
|
||||
|
||||
`EvidenceAwareJpaTransactionManager` marks the phase `COMMITTING` immediately before delegating to
|
||||
the provider commit and never after. If the network, the JVM, or the server dies inside that call,
|
||||
the last thing written is "we asked, we do not know" — which is exactly the state that must not be
|
||||
mistaken for a rollback.
|
||||
|
||||
Recovery is reconciliation, not retry:
|
||||
|
||||
```text
|
||||
record the transaction key -> check the idempotency record
|
||||
-> check the business row
|
||||
-> check the outbox
|
||||
-> still undetermined? reconciliation queue
|
||||
```
|
||||
|
||||
`CompletionUnknownRecorder` writes that record through a channel outside the unknown transaction.
|
||||
Writing it through the same connection would make the audit trail share the failure it documents.
|
||||
@@ -0,0 +1,771 @@
|
||||
# JPA Experimental Expansion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stable JPA 플랫폼을 변경하지 않고 Multi-tenancy, PostgreSQL RLS, schema/database tenant 분리, consistency-aware Read Replica, Jakarta Persistence 4.0, Hibernate ORM 8, PostgreSQL 19 호환성을 독립 Experimental 모듈과 승격 Gate로 검증한다.
|
||||
|
||||
**Architecture:** Experimental module은 Stable `jpa-core-api` 계약만 소비하며 Stable starter에 자동 포함되지 않는다. 각 기능은 명시적 feature flag와 별도 compatibility/failure suite를 요구한다. 실험 결과가 Stable 의미론과 충돌하면 Core를 왜곡하지 않고 capability 또는 별도 profile로 유지한다.
|
||||
|
||||
**Tech Stack:** Stable 계획의 Java 21·Spring Boot 4.1·PostgreSQL Testcontainers 기반, PostgreSQL RLS, AbstractRoutingDataSource, tenant-specific DataSource registry, Jakarta Persistence 4.0 preview/final compatibility lane, Hibernate ORM 8 compatibility lane, PostgreSQL 19 compatibility lane.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Stable 계획 Task 1~53이 완료되고 Release Gate가 통과한 뒤 시작한다.
|
||||
- 모듈 루트는 `modules/jpa-experimental`이다.
|
||||
- Experimental module은 `jpa-spring-boot-starter`의 기본 dependency가 아니다.
|
||||
- 모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다.
|
||||
- Tenant ID와 consistency token은 metric label에 기록하지 않는다.
|
||||
- Tenant context 누락은 fail-closed다.
|
||||
- `readOnly=true`만으로 replica routing하지 않는다.
|
||||
- Lock query, write transaction, read-after-write pin은 primary를 사용한다.
|
||||
- JPA4/Hibernate8/PG19 결과로 Stable 3.2/7.4/PG16~18 contract를 수정하지 않는다.
|
||||
- 승격 전 별도 security, failure, migration and compatibility evidence가 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Experimental 파일 구조
|
||||
|
||||
```text
|
||||
modules/jpa-experimental/
|
||||
├── jpa-experimental-core/
|
||||
├── jpa-multitenancy-column/
|
||||
├── jpa-multitenancy-rls/
|
||||
├── jpa-multitenancy-schema/
|
||||
├── jpa-multitenancy-database/
|
||||
├── jpa-read-replica/
|
||||
└── jpa-next-compatibility/
|
||||
```
|
||||
|
||||
---
|
||||
### Task 1: Experimental Module·Feature Gate·Dependency Isolation 구성
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java`
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java`
|
||||
- Modify: `settings.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Stable `jpa-core-api` and explicit environment feature flags.
|
||||
- Produces: Isolated experimental projects that cannot enter the Stable starter transitively.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Every module depends only on Stable public contracts, never on Stable internal packages.
|
||||
- Feature gate fails startup when module is present but flag is absent.
|
||||
- Add a dependency graph test proving the Stable starter has no experimental dependency.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental;
|
||||
|
||||
class ExperimentalFeatureGateTest {
|
||||
@Test
|
||||
void featureIsDisabledUnlessExplicitlyEnabled() {
|
||||
assertThatThrownBy(() -> gate.requireEnabled(MULTITENANCY_COLUMN, Map.of()))
|
||||
.hasMessageContaining("backend.jpa.experimental.multitenancy-column=true");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental;
|
||||
|
||||
public final class ExperimentalFeatureGate {
|
||||
public void requireEnabled(
|
||||
ExperimentalFeature feature,
|
||||
Map<String, Boolean> flags) {
|
||||
if (!Boolean.TRUE.equals(flags.get(feature.property()))) {
|
||||
throw new IllegalStateException(feature.property() + "=true is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-experimental-core/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts' 'modules/jpa-experimental/jpa-read-replica/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java' 'settings.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java'
|
||||
git commit -m "build: isolate jpa experimental modules"
|
||||
```
|
||||
|
||||
### Task 2: Shared-schema Tenant Context와 Column Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Explicit request/job tenant context and domain Entity tenant-column contracts.
|
||||
- Produces: Fail-closed tenant context propagation and query/write isolation evidence.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Reject Repository access when tenant context is absent outside an audited admin scope.
|
||||
- Require tenant column in unique/index requirements where isolation depends on it.
|
||||
- Test async job context propagation and cleanup.
|
||||
- Do not rely on Hibernate filter alone as the final security boundary.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.tenant;
|
||||
|
||||
class TenantColumnIsolationTest {
|
||||
@Test
|
||||
void tenantARepositoryCannotReadTenantBRows() {
|
||||
insertFor(TENANT_A, "a");
|
||||
insertFor(TENANT_B, "b");
|
||||
|
||||
assertThat(withTenant(TENANT_A, repository::findAll))
|
||||
.extracting(Item::value)
|
||||
.containsExactly("a");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.tenant;
|
||||
|
||||
public final class TenantContext {
|
||||
private static final ThreadLocal<TenantId> CURRENT = new ThreadLocal<>();
|
||||
|
||||
public static TenantId require() {
|
||||
TenantId tenant = CURRENT.get();
|
||||
if (tenant == null) throw new IllegalStateException("tenant context is required");
|
||||
return tenant;
|
||||
}
|
||||
|
||||
public static void clear() { CURRENT.remove(); }
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java'
|
||||
git commit -m "feat: add experimental tenant column isolation"
|
||||
```
|
||||
|
||||
### Task 3: PostgreSQL RLS Tenant Policy와 Connection Reuse Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: TenantContext, PostgreSQL transaction-local settings and restricted runtime role.
|
||||
- Produces: Database-enforced tenant isolation that resets safely across pooled connections.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Set tenant context with transaction-local `set_config` before tenant queries.
|
||||
- Prove a pooled connection cannot leak the prior tenant into the next transaction.
|
||||
- Runtime role must not own tables or bypass RLS.
|
||||
- Admin bypass requires a separate DataSource and audit token.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.rls;
|
||||
|
||||
class RlsIsolationFailureTest {
|
||||
@Test
|
||||
void pooledConnectionDoesNotLeakPriorTenantSetting() {
|
||||
withTenant(TENANT_A, () -> assertThat(repository.count()).isEqualTo(1));
|
||||
withTenant(TENANT_B, () -> assertThat(repository.count()).isEqualTo(1));
|
||||
withoutTenant(() -> assertThatThrownBy(repository::count).isInstanceOf(DataAccessException.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.rls;
|
||||
|
||||
public final class RlsTenantSessionBinder {
|
||||
public void bind(EntityManager entityManager, TenantId tenant) {
|
||||
entityManager.createNativeQuery(
|
||||
"select set_config('app.tenant_id', :tenant, true)")
|
||||
.setParameter("tenant", tenant.value())
|
||||
.getSingleResult();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql' 'modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java'
|
||||
git commit -m "feat: add experimental postgresql rls isolation"
|
||||
```
|
||||
|
||||
### Task 4: Schema-per-tenant Connection Provider와 Migration Orchestrator 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Validated tenant→schema catalog and Flyway migration gate.
|
||||
- Produces: Bounded schema selection and per-tenant migration status without accepting raw schema names.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Map TenantId to a pre-registered schema identifier; no user-provided SQL identifier.
|
||||
- Reset schema/search_path when returning pooled connections.
|
||||
- Track migration version and failure per tenant.
|
||||
- Rate-limit tenant migrations and support resume without auto-repair.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.schema;
|
||||
|
||||
class SchemaTenantMigrationContractTest {
|
||||
@Test
|
||||
void migratesOnlyRegisteredSchemasAndResumesAfterFailure() {
|
||||
orchestrator.migrateAll(List.of(TENANT_A, TENANT_B));
|
||||
assertThat(status(TENANT_A).version()).isEqualTo(LATEST);
|
||||
assertThatThrownBy(() -> orchestrator.migrate(new TenantId("../public")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.schema;
|
||||
|
||||
public final class SchemaTenantRegistry {
|
||||
public String requireSchema(TenantId tenant) {
|
||||
return Optional.ofNullable(schemaByTenant.get(tenant))
|
||||
.orElseThrow(() -> new IllegalArgumentException("unregistered tenant schema"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java'
|
||||
git commit -m "feat: add experimental schema per tenant persistence"
|
||||
```
|
||||
|
||||
### Task 5: Database-per-tenant DataSource Registry와 Capacity Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Secret-backed tenant connection profiles and global DB connection budget.
|
||||
- Produces: Lazy bounded per-tenant pools with eviction, credential rotation and migration status.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Never create an unbounded Hikari pool per tenant.
|
||||
- Enforce global maximum pools and connections before creating a DataSource.
|
||||
- Drain and close pools on tenant removal or credential rotation.
|
||||
- Do not expose tenant JDBC URLs or credentials in diagnostics.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.database;
|
||||
|
||||
class TenantPoolCapacityContractTest {
|
||||
@Test
|
||||
void refusesNewTenantPoolWhenGlobalConnectionBudgetIsExhausted() {
|
||||
registry.openTenants(globalBudget().maxTenants());
|
||||
assertThatThrownBy(() -> registry.require(ANOTHER_TENANT))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("tenant pool budget");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.database;
|
||||
|
||||
public record TenantPoolBudget(
|
||||
int maxOpenPools,
|
||||
int maxConnectionsAcrossPools) {
|
||||
public void requireCapacity(int openPools, int allocatedConnections) {
|
||||
if (openPools >= maxOpenPools || allocatedConnections >= maxConnectionsAcrossPools) {
|
||||
throw new IllegalStateException("tenant pool budget exhausted");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java'
|
||||
git commit -m "feat: add experimental database per tenant registry"
|
||||
```
|
||||
|
||||
### Task 6: Consistency-aware Read Replica Routing 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java`
|
||||
- Test: `modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Primary/replica DataSources, transaction state, lock intent and replica lag evidence.
|
||||
- Produces: Routing decisions for PRIMARY_REQUIRED, BOUNDED_STALENESS and EVENTUAL reads.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Writes, lock queries, REQUIRES_NEW writes and active write transactions always use primary.
|
||||
- Read-after-write uses a consistency token or primary pin, not `readOnly=true` alone.
|
||||
- Fallback to primary when replica lag exceeds policy or evidence is unavailable.
|
||||
- Keep routing fixed for the life of one transaction.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.replica;
|
||||
|
||||
class ReadAfterWriteRoutingContractTest {
|
||||
@Test
|
||||
void immediateReadAfterWriteUsesPrimaryUntilConsistencyTokenIsSatisfied() {
|
||||
var token = service.writeAndReturnConsistencyToken();
|
||||
var decision = router.route(readOnlyTransaction(), ReadConsistency.after(token));
|
||||
assertThat(decision.target()).isEqualTo(PRIMARY);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.replica;
|
||||
|
||||
public final class ConsistencyAwareDataSourceRouter {
|
||||
public ReplicaRoutingDecision route(
|
||||
TransactionContext transaction,
|
||||
ReadConsistency consistency) {
|
||||
if (transaction.write() || transaction.locking() ||
|
||||
!lagMonitor.satisfies(consistency)) {
|
||||
return ReplicaRoutingDecision.primary();
|
||||
}
|
||||
return ReplicaRoutingDecision.replica();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java' 'modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java'
|
||||
git commit -m "feat: add experimental consistency aware replica routing"
|
||||
```
|
||||
|
||||
### Task 7: Jakarta Persistence 4.0 Compatibility Lane 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java`
|
||||
- Create: `.github/workflows/jpa-next-jpa4.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Published Jakarta Persistence 4.0 milestone/final artifact when available and the Stable contract suite.
|
||||
- Produces: A non-blocking compatibility report that does not alter Stable JPA 3.2 APIs.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Run the Stable public API compilation and selected mapping contracts against JPA 4.
|
||||
- Record removed/changed APIs and provider support separately.
|
||||
- Do not publish JPA4 compiled artifacts under Stable coordinates.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```kotlin
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class CompatibilityLaneDefinitionTest {
|
||||
@Test
|
||||
void jpaFourLaneIsExperimentalAndSeparateFromStablePublication() {
|
||||
assertThat(lane("jpa4").publicationEnabled()).isFalse();
|
||||
assertThat(lane("jpa4").supportLevel()).isEqualTo(EXPERIMENTAL);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```kotlin
|
||||
testing {
|
||||
suites {
|
||||
register<JvmTestSuite>("compatibilityJpa4") {
|
||||
useJUnitJupiter()
|
||||
dependencies {
|
||||
implementation(project(":modules:jpa:jpa-core-api"))
|
||||
implementation(libs.jakarta.persistence.next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java' '.github/workflows/jpa-next-jpa4.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java'
|
||||
git commit -m "test: add jakarta persistence four compatibility lane"
|
||||
```
|
||||
|
||||
### Task 8: Hibernate ORM 8 Compatibility Lane 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java`
|
||||
- Create: `.github/workflows/jpa-next-hibernate8.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Hibernate ORM 8 milestone/final artifact and Stable Hibernate 7.4 regression suites.
|
||||
- Produces: Generated SQL, fetch pagination, statistics, batch and extension compatibility evidence.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Re-run collection fetch pagination, StatementInspector, Statistics, JSONB, Batch and StatelessSession contracts.
|
||||
- Record SQL and performance differences without weakening the 7.4 Stable gate.
|
||||
- Do not allow Hibernate 8 dependencies in Stable published modules.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```kotlin
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class HibernateCompatibilityPolicyTest {
|
||||
@Test
|
||||
void hibernateEightCannotReplaceStableProviderWithoutPromotion() {
|
||||
assertThat(policy.stableProvider()).isEqualTo("7.4");
|
||||
assertThat(policy.experimentalProviders()).contains("8");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```kotlin
|
||||
testing {
|
||||
suites {
|
||||
register<JvmTestSuite>("compatibilityHibernate8") {
|
||||
useJUnitJupiter()
|
||||
dependencies {
|
||||
implementation(project(":modules:jpa:jpa-testkit-postgresql"))
|
||||
implementation(libs.hibernate.orm.next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java' '.github/workflows/jpa-next-hibernate8.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java'
|
||||
git commit -m "test: add hibernate eight compatibility lane"
|
||||
```
|
||||
|
||||
### Task 9: PostgreSQL 19 Compatibility와 Stable 승격 Gate 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java`
|
||||
- Create: `docs/jpa/experimental-support-matrix.md`
|
||||
- Create: `docs/jpa/experimental-promotion-checklist.md`
|
||||
- Create: `.github/workflows/jpa-next-postgresql19.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: PG19 image when GA, all Stable contracts, experimental security/failure/migration/performance reports.
|
||||
- Produces: A promotion decision that requires evidence rather than version availability alone.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Run mapping, SQLSTATE, lock, batch, Flyway, plan and native extension contracts on PG19.
|
||||
- Promotion requires two supported patch runs and no unresolved semantic regression.
|
||||
- Multi-tenancy/replica promotion requires tenant leakage, failover, lag and pool-capacity evidence.
|
||||
- Update Stable support matrix only through a reviewed ADR.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class ExperimentalPromotionGateTest {
|
||||
@Test
|
||||
void promotionRequiresAllEvidenceAndReviewedAdr() {
|
||||
var evidence = evidence().withCompatibility(true).withSecurity(true).withFailure(true)
|
||||
.withMigration(true).withPerformance(true).withReviewedAdr(false);
|
||||
assertThat(gate.evaluate(evidence)).isEqualTo(BLOCKED_MISSING_ADR);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
public final class ExperimentalPromotionGate {
|
||||
public PromotionDecision evaluate(PromotionEvidence evidence) {
|
||||
if (!evidence.allTechnicalGatesPassed()) return BLOCKED_TECHNICAL;
|
||||
if (!evidence.reviewedAdr()) return BLOCKED_MISSING_ADR;
|
||||
return ELIGIBLE_FOR_STABLE_REVIEW;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java' 'docs/jpa/experimental-support-matrix.md' 'docs/jpa/experimental-promotion-checklist.md' '.github/workflows/jpa-next-postgresql19.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java'
|
||||
git commit -m "docs: add jpa experimental promotion gates"
|
||||
```
|
||||
## 2. Experimental 완료 조건
|
||||
|
||||
```text
|
||||
Stable starter가 Experimental module에 의존하지 않는다.
|
||||
Tenant context 누락과 connection reuse에서 fail-closed다.
|
||||
RLS runtime role이 policy를 bypass하지 못한다.
|
||||
Schema/database tenant migration과 pool capacity가 bounded다.
|
||||
Replica routing이 read-after-write와 lock query를 primary에 고정한다.
|
||||
JPA4/Hibernate8/PG19 lane이 Stable artifacts를 변경하지 않는다.
|
||||
승격은 ADR와 compatibility/security/failure/migration/performance 증거를 요구한다.
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
# infra/jpa/postgres
|
||||
|
||||
Server-side settings the JPA platform's contracts assume, and why each one matters.
|
||||
|
||||
The contract suites start their own containers through
|
||||
`dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory`, so
|
||||
nothing here is needed to run them. This directory records what a *deployed* PostgreSQL has to look
|
||||
like for the platform's guarantees to hold, because several of them are server settings rather than
|
||||
application code.
|
||||
|
||||
## Settings the platform depends on
|
||||
|
||||
| Setting | Why the platform cares |
|
||||
|---|---|
|
||||
| `statement_timeout` | The last bound on a runaway statement. The platform sets transaction timeouts, but a single statement inside a transaction can still outlive the request that asked for it. |
|
||||
| `idle_in_transaction_session_timeout` | An idle open transaction holds its locks and its snapshot indefinitely, which blocks writers and prevents vacuum. This is what turns "someone left a transaction open" into a bounded incident. |
|
||||
| `lock_timeout` | A cluster-wide floor under the per-request lock bounds in `PostgreSqlLockOptions`. |
|
||||
| `max_connections` | The number `app.jpa-platform.datasource.maximum-pool-size` must be sized against — across every instance, and allowing for `REQUIRES_NEW` taking a second connection while pinning the first. |
|
||||
| `default_transaction_isolation` | Left at `read committed`. The platform selects `repeatable read` or `serializable` per transaction profile; changing the default would silently change every transaction that did not ask. |
|
||||
|
||||
## Suggested baseline
|
||||
|
||||
```conf
|
||||
statement_timeout = '30s'
|
||||
idle_in_transaction_session_timeout = '60s'
|
||||
lock_timeout = '10s'
|
||||
default_transaction_isolation = 'read committed'
|
||||
```
|
||||
|
||||
These are starting points, not recommendations: the right `statement_timeout` depends on the
|
||||
slowest legitimate query in the application, and setting it below that turns a working report into
|
||||
an error. Measure before pinning.
|
||||
|
||||
## What is deliberately not configured here
|
||||
|
||||
- **Roles.** Credential separation lives in [`../roles/runtime-roles.sql`](../roles/runtime-roles.sql).
|
||||
- **Schema.** Flyway owns it (design §31). Nothing in this directory creates a table.
|
||||
- **Extensions.** The platform's PostgreSQL support — JSONB, arrays, ranges, `SKIP LOCKED`,
|
||||
`ON CONFLICT` — is all core PostgreSQL. No extension is required, and none should be assumed.
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Runtime / migration / admin credential separation for the JPA persistence platform.
|
||||
-- Design §36; enforced at startup by PostgreSqlRuntimeRoleVerifier + DatabaseRolePolicy.
|
||||
--
|
||||
-- The separation is what makes "Flyway owns schema change" enforceable rather than aspirational.
|
||||
-- If the application's own credential cannot execute DDL, then no code path, no library, and no
|
||||
-- injected statement can alter the schema at runtime — regardless of what the application intended.
|
||||
--
|
||||
-- Run as a superuser once per database. Replace the placeholder passwords with values from the
|
||||
-- deployment's secret store; they are intentionally not committed.
|
||||
|
||||
-- 1. The schema the application owns. Owned by the migration role, not the runtime role.
|
||||
create schema if not exists app authorization app_migration;
|
||||
|
||||
-- 2. Roles.
|
||||
-- app_migration : owns the schema, applies Flyway migrations. DDL.
|
||||
-- app_runtime : the application's credential. DML only, no DDL, no CREATE.
|
||||
-- app_admin : J4 operations — COPY, backfill, maintenance. Never used by request paths.
|
||||
create role app_migration login password 'REPLACE_FROM_SECRET_STORE';
|
||||
create role app_runtime login password 'REPLACE_FROM_SECRET_STORE';
|
||||
create role app_admin login password 'REPLACE_FROM_SECRET_STORE';
|
||||
|
||||
-- 3. Revoke the PUBLIC grants that make the checks in DatabaseRolePolicy necessary.
|
||||
-- Before PostgreSQL 15, PUBLIC held CREATE on the public schema — which is how an unprivileged
|
||||
-- role ends up able to plant an object that shadows a real one through search_path.
|
||||
revoke all on database current_database() from public;
|
||||
revoke create on schema public from public;
|
||||
|
||||
-- 4. Runtime: read and write rows in the application schema. Nothing else.
|
||||
grant connect on database current_database() to app_runtime;
|
||||
grant usage on schema app to app_runtime;
|
||||
grant select, insert, update, delete on all tables in schema app to app_runtime;
|
||||
grant usage, select on all sequences in schema app to app_runtime;
|
||||
|
||||
-- Tables created by future migrations must inherit the same grants, or the first deployment after
|
||||
-- a new table silently fails at runtime with a permission error.
|
||||
alter default privileges for role app_migration in schema app
|
||||
grant select, insert, update, delete on tables to app_runtime;
|
||||
alter default privileges for role app_migration in schema app
|
||||
grant usage, select on sequences to app_runtime;
|
||||
|
||||
-- 5. Explicitly deny the two privileges the startup verifier checks for.
|
||||
revoke create on schema app from app_runtime;
|
||||
revoke create on database current_database() from app_runtime;
|
||||
|
||||
-- 6. Admin: bulk operations under an audited identity, still without schema ownership.
|
||||
grant connect on database current_database() to app_admin;
|
||||
grant usage on schema app to app_admin;
|
||||
grant select, insert, update, delete on all tables in schema app to app_admin;
|
||||
alter default privileges for role app_migration in schema app
|
||||
grant select, insert, update, delete on tables to app_admin;
|
||||
|
||||
-- 7. Pin the runtime search_path so an unqualified name cannot resolve anywhere unexpected.
|
||||
alter role app_runtime set search_path = app, pg_catalog;
|
||||
alter role app_admin set search_path = app, pg_catalog;
|
||||
@@ -0,0 +1,43 @@
|
||||
# Commit-ambiguity failure injection for the JPA platform (design §39).
|
||||
#
|
||||
# The suite needs a proxy rather than a kill switch because the scenario that matters cannot be
|
||||
# produced any other way. Stopping the container, killing the process, or closing the client socket
|
||||
# all break *before* the server commits — the easy case, where the transaction rolled back and the
|
||||
# use case may simply be re-run. The hard case is a commit the server completed whose
|
||||
# acknowledgement never came back, and it only exists if you can cut the return path while leaving
|
||||
# the forward path intact.
|
||||
#
|
||||
# That is what CommitAmbiguityProxy does with a downstream-only toxic, and it is the one scenario
|
||||
# that distinguishes a platform that reports completion-unknown from one that retries a write which
|
||||
# already succeeded.
|
||||
#
|
||||
# Ordinary contract runs use Testcontainers and do not need this file; it exists for reproducing a
|
||||
# failure scenario by hand.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: jpa_failure
|
||||
POSTGRES_USER: jpa_failure
|
||||
POSTGRES_PASSWORD: jpa_failure
|
||||
# No published port: the suite must reach PostgreSQL only through the proxy, or the injected
|
||||
# fault can be bypassed by connecting directly and the test passes without testing anything.
|
||||
expose:
|
||||
- "5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U jpa_failure -d jpa_failure"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
toxiproxy:
|
||||
image: ghcr.io/shopify/toxiproxy:2.11.0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# 8474 is the control API the suite drives; 8666 is the proxied PostgreSQL port.
|
||||
- "8474:8474"
|
||||
- "8666:8666"
|
||||
command: ["-host", "0.0.0.0"]
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user