Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4e72087
Bump version -> `2.0.0-SNAPSHOT.220`
alexander-yevsyukov Aug 5, 2026
e15361a
Update `config`
alexander-yevsyukov Aug 5, 2026
b760b67
Restore Testcontainers 2.0.5 after the `config` update
alexander-yevsyukov Aug 5, 2026
619ad93
Bump local dependencies
alexander-yevsyukov Aug 5, 2026
af9d07b
Update dependency reports
alexander-yevsyukov Aug 5, 2026
90cb5b5
Remove outdated test suites
alexander-yevsyukov Aug 5, 2026
62ed5f8
Adopt new CoreJvm storage API
alexander-yevsyukov Aug 5, 2026
c821930
Clean up `DsRecordStorage`
alexander-yevsyukov Aug 5, 2026
67f05eb
Honor `StorageGroup` in Datastore kind allocation
alexander-yevsyukov Aug 5, 2026
247a2f5
Bump version -> `2.0.0-SNAPSHOT.220`
alexander-yevsyukov Aug 5, 2026
338e150
Address Gradle 10 deprecations
alexander-yevsyukov Aug 5, 2026
8d1d34d
Annotate `serialVersionUID` fields with `@Serial`
alexander-yevsyukov Aug 5, 2026
1e754f1
Make grouped kind names collision-free
alexander-yevsyukov Aug 5, 2026
5c6ec48
Fail fast on `null` arguments in the grouped `find`
alexander-yevsyukov Aug 5, 2026
26dce91
Update `config`
alexander-yevsyukov Aug 6, 2026
fa9c85e
Address warnings
alexander-yevsyukov Aug 6, 2026
7a4548b
Key grouped layouts by `StorageGroup`
alexander-yevsyukov Aug 6, 2026
7e6cedb
Extract the grouped-kind separator into a constant
alexander-yevsyukov Aug 6, 2026
795a606
Improve class names of test fixtures
alexander-yevsyukov Aug 6, 2026
e26560e
Update build time
alexander-yevsyukov Aug 6, 2026
79dd8e5
Bump Gradle Wrapper validation action -> @v6
alexander-yevsyukov Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .agents/tasks/de-event-sourcing-rollout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Adopt the de-event-sourcing storage API of core-jvm (Phase H rollout)

Upstream plan: `core-jvm/.agents/tasks/de-event-sourcing-plan.md`, Phase H.
Branch: `de-event-sourcing`. Depends on core-jvm `2.0.0-SNAPSHOT.522`.
Reference implementation: `jdbc-storage@de-event-sourcing` (PR #181 train),
which settled the vendor conventions with the product owner on 2026-08-04.

## Problem

Event-sourced aggregate loading is removed in `core-jvm`. For storage vendors:

- `AggregateStorage`, `StorageFactory.createAggregateStorage`, and the
published `AggregateStorageTest`/`AggregateHistoryTruncationTest` fixtures
are **removed**. Aggregate latest state arrives via
`createEntityRecordStorage`/`createRecordStorage` with `group == null`.
- `createRecordStorage` gained a `@Nullable StorageGroup group` parameter and
is now the factory's only abstract method. Non-null groups arrive from the
per-entity histories (`EntityEventStorage`, `EntityStateHistoryStorage`),
both named after the entity state type. The vendor must allocate physical
storage by the **(source type, record type, group)** triple.
- `createEntityStateHistoryStorage` may be invoked concurrently (delivery
worker threads); the factory must tolerate that.
- The published `RecordStorageDelegateTest` base is now `DelegatingRecordStorageTest`.

Without honoring the group, Datastore kind identity (derived from
`RecordSpec.sourceType()` alone via `RecordLayout` → `Kind.of(domainType)`)
conflates: all event journals with each other **and** with the event log
(`sourceType == Event` everywhere), and an entity's state history with its
latest-state kind (both `sourceType == the state class`,
records stored as `EntityRecord`s).

## Fix

Mechanical half:

- 3-arg `createRecordStorage` in `DatastoreStorageFactory`.
- `DsRecordStorageTest` retargeted at `DelegatingRecordStorageTest`.
- Obsolete `DsAggregateStorageTest`, `DsAggregateStorageTruncationTest`
deleted (their published bases are gone).
- Compile-driven fallout fixes across `datastore` and `testlib`.

Substantive half:

- Grouped kind naming follows the jdbc rule (generic rule over semantic
suffixes): grouped kind = group name + `-` + record type simple name,
e.g. `spine.test.storage.StgProject-Event`. Implemented as a new
`Kind.of(recordType, group)` factory method.
- `DatastoreStorageFactory.configurationWith(...)` threads the group into
the layout choice: grouped storages always take a `FlatLayout` with the
grouped kind. Custom layouts (`organizeRecords`) and custom storages
(`useRecordStorage`/`useEntityStorage`) keep applying to **ungrouped**
storages only — honoring either for a grouped storage would re-create the
collision (or hand the history to a storage meant for latest state).
- `TxSettings` stay keyed by `sourceType` and therefore also serve grouped
storages (mirrors jdbc's decision that source-type-keyed settings extend
to grouped tables).
- `DatastoreStorageFactory.wrapperFor(...)` becomes `computeIfAbsent` —
the check-then-put race matters now that the state history storage is
created lazily on delivery worker threads.

New Kotlin specs (Datastore emulator, mirroring jdbc's suite):

- `GroupedKindAllocationSpec` — the vendor allocation contract: distinct
kinds per (source type, record type, group); `null` group unchanged.
- `DsEntityEventStorageSpec` — journal round-trip, `historyBackward` window, `truncate`.
- `DsEntityStateHistoryStorageSpec` — round-trips incl. the `EntityStateKey`
Message ID, upsert overwrite on same version, `stateAt`, `trim`, `truncate`.
- `ConcurrentHistoryCreationSpec` — concurrent
`createEntityStateHistoryStorage` tolerance.

## Settled while implementing

- Grouped kind naming: `Kind.of(recordType, group)` → group name + `-` +
record type simple name (`spine.test.storage.StgProject-Event`), following
the jdbc "group + record type" rule (product owner, 2026-08-04). The joiner
is a dash — a character illegal in Protobuf type names — so a grouped kind
never collides with a type-name-derived ungrouped kind, even for sibling
types like `Order` and `Order_Event` (Codex review of PR #204; jdbc's `_`
joiner retains that ambiguity under SQL identifier constraints).
- `RecordLayout`/`FlatLayout` gained `Kind`-accepting constructors, so a
grouped storage can carry a kind not derived from a record type alone.
- The two dead suites (`DsAggregateStorageTest`,
`DsAggregateStorageTruncationTest`) are deleted;
`DsRecordStorageTest` retargeted at `DelegatingRecordStorageTest`.
- The branch's `Update config` had reverted the Testcontainers `2.0.5` pin
(PR #202) back to config's `1.21.4`, breaking `testlib` compilation.
The pin is re-applied, now in `config`'s KDoc style with the 2.x
`testcontainers-` artifact renames spelled out — the shape intended for
the `config` repo, where the lasting fix belongs (flagged as
a separate task).

- Custom naming/layout for grouped kinds (the Datastore analog of jdbc's
`setTableName(stateType, recordType, name)` overload, requested for jdbc
in the review of its PR #181): the new
`organizeRecords(stateType, recordType, layout)` builder overload
registers a `RecordLayout` — carrying a custom kind, an ancestor
structure, or both — for the grouped storage addressed by the storage
group (named by the framework after the entity state type) paired with
the record type. Registrations live in `RecordLayouts` keyed by
`(group name, record type)`; the single-type `organizeRecords` keeps
applying to ungrouped storages only. `EntityGroupLayout` gained
a `Kind`-accepting constructor for parity with `FlatLayout`.

## Follow-ups (out of scope)

- `DsRecordStorage.deleteRecord()` always returns `true` (documented: telling
would take another Datastore request), deviating from the
`RecordStorage.delete` contract ("`false` if not found") that
`HistoryStorage.delete` re-exposes. Decide whether the backend should pay
the existence check, or the framework KDoc should allow the deviation.

## Status

Implemented; the four history specs (30 cases) pass against the emulator.
Delete this file on merge to master.
7 changes: 6 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"plansDirectory": ".claude/plans",
"permissions": {
"allow": [
"Edit(version.gradle.kts)",
"Bash(./gradlew:*)",
"Bash(./config/gradlew:*)",
"Bash(git status:*)",
Expand Down Expand Up @@ -31,8 +33,11 @@
"Bash(mkdir:*)",
"Bash(touch:*)",
"Bash(python3 .agents/skills/update-copyright/scripts/update_copyright.py:*)",
"Bash(.agents/skills/version-bumped/scripts/version-bumped.sh)",
"Bash(./config/pull)",
"Bash(./config/migrate)"
"Bash(./config/migrate)",
"Skill(pre-pr)",
"Skill(pre-pr:*)"
],
"deny": [
"Bash(git reset --hard:*)",
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/gradle-wrapper-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ jobs:
uses: actions/checkout@v6

- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v4
uses: gradle/actions/wrapper-validation@v6
70 changes: 65 additions & 5 deletions .github/workflows/increment-guard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Ensures that the current lib version is not yet published by executing the Gradle
# `checkVersionIncrement` task.
# Guards the project version by executing the Gradle `checkVersionIncrement` task,
# which verifies that the version is both (a) strictly greater than the base branch
# version in `version.gradle.kts` and (b) not already published. The result is
# published as the `Version Guard` commit status — the context required by branch
# protection and re-published by `revalidate-versions.yml` on the heads of other open
# PRs when the base branch advances (so a stale duplicate bump turns red before merge).
#
# The check runs only for pull requests targeting a default (`master`/`main`) or
# a release-line (e.g. `2.x-jdk8-master`) branch. It is the responsibility of a branch
Expand All @@ -15,26 +19,82 @@ name: Version Guard

on:
pull_request:
# Beyond the default activity types (`opened`, `synchronize`, `reopened`), two more are
# needed because they change what the guard must compare against without a new head SHA,
# which would otherwise leave a stale-green `Version Guard` status mergeable:
# * `ready_for_review` — a draft that went stale while in draft becomes ready; and
# * `edited` — the base branch is retargeted (e.g. a release line -> `master`), so the
# strict comparison must be recomputed against the new base.
types: [opened, synchronize, reopened, ready_for_review, edited]

jobs:
check:
name: Check version increment
runs-on: ubuntu-latest
# Default and release-line branches, e.g. `master`, `main`, `2.x-jdk8-master`.
if: endsWith(github.base_ref, 'master') || endsWith(github.base_ref, 'main')
# Default and release-line branches, e.g. `master`, `main`, `2.x-jdk8-master`. For an
# `edited` event, run only when the base actually changed (a retarget carries
# `changes.base.ref.from`); title/body edits carry no `changes.base` and are skipped, so
# the guard is not rebuilt needlessly.
if: >-
(endsWith(github.base_ref, 'master') || endsWith(github.base_ref, 'main'))
&& (github.event.action != 'edited' || github.event.changes.base.ref.from != '')

# `statuses: write` lets the job publish the `Version Guard` commit status that
# branch protection requires.
permissions:
contents: read
statuses: write

steps:
- uses: actions/checkout@v6
with:
submodules: 'true'

# `checkVersionIncrement` reads `origin/<base>:version.gradle.kts`. The pull request
# checkout does not include the base branch, so fetch its tip into the expected ref.
- name: Fetch the base branch
shell: bash
run: |
git fetch --no-tags --depth=1 \
origin "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}"

- uses: actions/setup-java@v5
with:
java-version: 17
distribution: zulu

- uses: gradle/actions/setup-gradle@v6

- name: Check version is not yet published
- name: Check version increment
id: guard
shell: bash
# `VERSION_GUARD` enables the strict base-branch comparison in `checkVersionIncrement`.
# Only this workflow fetches the base ref (the step above), so the comparison is gated
# to it: other CI builds pull the task in via `publishToMavenLocal` on a shallow
# checkout and must not attempt to read `origin/<base>`.
env:
VERSION_GUARD: "true"
run: ./gradlew checkVersionIncrement --stacktrace

# Publish the verdict as the `Version Guard` commit status on the PR head. Posting it
# on every run (success or failure) is what lets a later re-bump clear a failure that
# `revalidate-versions.yml` set when the base branch advanced.
#
# Skipped for fork PRs: `GITHUB_TOKEN` is read-only for them, so the status cannot be
# posted (and a fork head SHA is not in this repo). The Spine agent workflow pushes PR
# branches to the same repository; fork contributions are handled by a maintainer, who
# owns the version bump.
- name: Report the Version Guard status
if: always() && github.event.pull_request.head.repo.fork == false
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
state=failure
if [ "${{ steps.guard.outcome }}" = "success" ]; then
state=success
fi
gh api -X POST "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
-f state="${state}" \
-f context="Version Guard" \
-f description="Version increment check"
15 changes: 15 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,18 @@ jobs:
REPO_SLUG: ${{ github.repository }} # e.g. SpineEventEngine/core-jvm
GOOGLE_APPLICATION_CREDENTIALS: ./maven-publisher.json
NPM_TOKEN: ${{ secrets.NPM_SECRET }}

# A failed publication on `master` is most often a version collision: a stale
# duplicate bump merged before `revalidate-versions.yml` could turn it red (the
# narrow auto-merge race). The artifact is safe — the registry rejects the
# overwrite — but the fix needs a human/agent, so make the failure loud and
# actionable instead of a quiet red run.
- name: Report a failed publication
if: failure()
shell: bash
run: |
echo "::error title=Publish failed::Publishing to Maven failed on the base branch. If this is a version collision, the version is already published (immutable). Bump 'version.gradle.kts' on the base branch (e.g. via a small PR) and re-run this workflow."
echo "Publish failed. If the cause is a version collision:"
echo " 1. Bump 'version.gradle.kts' on the base branch to the next free version."
echo " 2. Re-run this 'Publish' workflow."
echo "Stale duplicate bumps are normally caught before merge by 'revalidate-versions.yml'."
57 changes: 57 additions & 0 deletions .github/workflows/revalidate-versions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Re-judges every other open pull request when the base branch advances.
#
# Publishing runs on every push to a release base branch, so once one pull request merges
# and bumps the version, any other open PR that bumped to the same (or a lower) value is
# now stale: its publish would collide. GitHub does not re-run a PR's checks when its base
# advances, so this workflow does it actively — for each other open PR whose
# `version.gradle.kts` version is `<=` the new base version, it posts a failing
# `Version Guard` commit status on the PR head, blocking the merge until the author
# re-bumps. The status self-clears: the re-bump push runs `increment-guard.yml`, which
# posts a fresh `success` on the new head.
#
# This narrows, but does not close, the race against auto-merge. A PR that is already
# mergeable can merge in the seconds before this fan-out marks it stale; that late merge
# produces a publish collision which the immutable Maven registry rejects (a loud,
# recoverable red Publish), never an overwrite. The deterministic guarantee is the
# registry's immutability, not this signal.

name: Revalidate Versions

on:
push:
# Matches the PR guard's `endsWith(base_ref, 'master'|'main')` and the same scope as
# `build-on-ubuntu.yml`. `**` (unlike `*`) also crosses `/`, so slash-named release
# lines such as `release/2.x-master` are covered. Keeping these definitions identical
# ensures every branch guarded on the PR side is also revalidated here.
branches:
- '**master'
- '**main'

permissions:
contents: read
statuses: write
pull-requests: read

concurrency:
# Only the newest tip of a given base matters; a later push to the same ref cancels an
# in-flight fan-out for it. Different bases run independently.
group: revalidate-versions-${{ github.ref }}
cancel-in-progress: true

jobs:
revalidate:
name: Revalidate open PRs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: 'true'

- name: Revalidate open PRs against the new base version
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BASE_REF: ${{ github.ref_name }}
# Invoked via `bash` so it does not depend on the script's committed executable bit.
run: bash ./config/scripts/revalidate-versions.sh
13 changes: 11 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,12 @@
.idea/modules
.idea/shelf

# `.idea/misc.xml` is intentionally NOT re-included below. It is project-local —
# it holds the per-project JDK name and IDEA's own churn (entry-point list
# indices, external-storage toggles) — so `.idea/*.xml` above keeps it ignored.
# `./config/pull` (via `migrate`) untracks any copy an earlier pull committed.

# Do not ignore the following IDEA settings
!.idea/misc.xml
!.idea/codeStyleSettings.xml
!.idea/codeStyles/
!.idea/copyright/
Expand Down Expand Up @@ -173,6 +177,12 @@ __pycache__/

# Claude working files
/.claude/worktrees/
# Ephemeral plan-mode scratch (durable task docs live in `.agents/tasks/`).
/.claude/plans/

# Personal, per-developer Claude Code settings overrides (never committed;
# the distributed `.claude/settings.json` is the shared, committed layer).
/.claude/settings.local.json

# Auto-downloaded Lychee binary used by the `check-links` skill.
/.agents/skills/check-links/.cache/
Expand All @@ -191,7 +201,6 @@ docs/_preview/resources/

# >>> repo-local entries (preserved across ./config/pull) >>>
# Copyright 2026, TeamDev. All rights reserved.
!.idea/misc.xml
!.idea/codeStyleSettings.xml
!.idea/codeStyles/
!.idea/copyright/
Expand Down
2 changes: 1 addition & 1 deletion .idea/live-templates/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading