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:
DongHyeonka
2026-08-14 14:06:18 +09:00
co-authored by Claude Opus 5
parent 3b5aee50e3
commit 0e61f86eb5
401 changed files with 34504 additions and 197 deletions
+6
View File
@@ -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'
+43
View File
@@ -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
+43
View File
@@ -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
+131
View File
@@ -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
+114
View File
@@ -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
+73
View File
@@ -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`.
+64
View File
@@ -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.
+43
View File
@@ -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.
+64
View File
@@ -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.
+61
View File
@@ -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.
+72
View File
@@ -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.
+74
View File
@@ -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.
+156
View File
@@ -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.
+85
View File
@@ -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.
+66
View File
@@ -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.
+79
View File
@@ -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.
+66
View File
@@ -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
+39
View File
@@ -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.
+54
View File
@@ -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;
+43
View File
@@ -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=
@@ -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);
}
}
@@ -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();
}
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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();
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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
}
@@ -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
}
@@ -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);
}
@@ -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);
}
@@ -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
}
@@ -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;
}
}
@@ -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
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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
}
@@ -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();
}
}
@@ -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");
}
}
}
}
@@ -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;
}
}
@@ -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());
}
}
@@ -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) {
@@ -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");
}
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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);
}
@@ -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";
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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