[![License](https://img.shields.io/github/license/octaviospain/lirp)](https://www.gnu.org/licenses/gpl-3.0) [![Kotlin](https://img.shields.io/badge/Kotlin-2.4.0-blue?logo=kotlin)](https://kotlinlang.org) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/octaviospain/lirp) [![lirp-api javadoc](https://javadoc.io/badge2/net.transgressoft/lirp-api/javadoc.svg?label=lirp-api%20javadoc)](https://javadoc.io/doc/net.transgressoft/lirp-api) [![lirp-core javadoc](https://javadoc.io/badge2/net.transgressoft/lirp-core/javadoc.svg?label=lirp-core%20javadoc)](https://javadoc.io/doc/net.transgressoft/lirp-core) [![lirp-sql javadoc](https://javadoc.io/badge2/net.transgressoft/lirp-sql/javadoc.svg?label=lirp-sql%20javadoc)](https://javadoc.io/doc/net.transgressoft/lirp-sql) [![lirp-fx javadoc](https://javadoc.io/badge2/net.transgressoft/lirp-fx/javadoc.svg?label=lirp-fx%20javadoc)](https://javadoc.io/doc/net.transgressoft/lirp-fx) ![Maven Central Version](https://img.shields.io/maven-central/v/net.transgressoft/lirp-api) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=octaviospain_lirp&metric=bugs)](https://sonarcloud.io/summary/new_code?id=octaviospain_lirp) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=octaviospain_lirp&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=octaviospain_lirp) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=octaviospain_lirp&metric=coverage)](https://sonarcloud.io/summary/new_code?id=octaviospain_lirp) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=octaviospain_lirp&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=octaviospain_lirp) [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=octaviospain_lirp&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=octaviospain_lirp) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=octaviospain_lirp&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=octaviospain_lirp) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/octaviospain/lirp/.github%2Fworkflows%2Fmaster.yml?logo=github) # LIRP - Lightweight Reactive Persistence A Kotlin/Java library where domain entities own their reactivity — property changes automatically notify subscribers, and repositories persist transparently. ## Contents - [What is LIRP?](#what-is-lirp) - [Why LIRP?](#why-lirp) - [Quick Start](#quick-start) - [SQL Persistence](#sql-persistence) - [Query DSL](#query-dsl) - [Kafka Integration](#kafka-integration) - [Features](#features) - [Limitations and Design Trade-offs](#limitations-and-design-trade-offs) - [Performance](#performance) - [Logging](#logging) - [Upgrading](#upgrading-to-v310) - [Documentation](#documentation) - [Contributing](#contributing) - [License and Attributions](#license-and-attributions) ## What is LIRP? LIRP solves a specific problem: **most reactive libraries make you wire streams manually, and most persistence libraries treat entities as passive data**. LIRP does both — your entities are reactive objects that automatically notify subscribers on property changes, and repositories keep your data in sync with a database or file. **The core idea:** domain entities should carry both behavior and reactivity. When you declare `var price by reactiveProperty(initial)`, the property becomes observable. When you add an entity to a repository, persistence happens automatically. ## Why LIRP? | Approach | Entities are reactive? | Auto-persist changes? | Wiring needed? | |---|---|---|---| | **LIRP** | ✅ Built-in via `reactiveProperty()` | ✅ Transparent (SQL/JSON) | None | | RxJava / Kotlin Flow | ❌ You wire streams yourself | ❌ Not a concern | Extensive | | Event Sourcing (fmodel, occurrent) | ❌ Events stored & replayed | ✅ But event-focused | Moderate | | Hibernate Reactive | ❌ ORM-managed | ✅ But requires sessions | Moderate | | Event Bus (Guava, Event-Library) | ❌ Separate infrastructure | ❌ Not a concern | Manual | LIRP's sweet spot: **small-to-medium datasets where entities need both reactivity and persistence with zero boilerplate** — configuration stores, user preferences, catalog management, any bounded context where the working set fits in memory. Built on Kotlin Coroutines and Kotlin Serialization. Targets **JVM 21 toolchain, JVM 17+ runtime, Kotlin 2.4.0**. ## Quick Start ### Installation LIRP is published to Maven Central under the `net.transgressoft` group. Pull `lirp-core` for the reactive entity + in-memory / JSON repository surface; add `lirp-sql` for the SQL repository and `lirp-fx` for the JavaFX bridge. The `net.transgressoft.lirp.sql` Gradle plugin wires up the KSP processor for you so generated accessors are produced at build time. **Requirements:** JVM 21 toolchain (JVM 17+ runtime), Kotlin 2.4.0. Gradle (Kotlin DSL): ```kotlin plugins { id("net.transgressoft.lirp.sql") version "" } dependencies { implementation("net.transgressoft:lirp-core:") implementation("net.transgressoft:lirp-sql:") // optional — SQL persistence implementation("net.transgressoft:lirp-fx:") // optional — JavaFX bridge } ``` Gradle without the LIRP plugin (manual KSP wiring): ```kotlin plugins { id("com.google.devtools.ksp") version "" } dependencies { implementation("net.transgressoft:lirp-core:") ksp("net.transgressoft:lirp-ksp:") implementation("net.transgressoft:lirp-sql:") // optional implementation("net.transgressoft:lirp-fx:") // optional } ``` Maven: ```xml net.transgressoft lirp-core ${lirp.version} net.transgressoft lirp-sql ${lirp.version} net.transgressoft lirp-fx ${lirp.version} com.dyescape kotlin-maven-symbol-processing ${ksp-maven.version} net.transgressoft:lirp-ksp:${lirp.version} ``` See [Consuming LIRP](https://github.com/octaviospain/lirp/wiki/Consuming-LIRP) for the compatibility matrix and troubleshooting common setup failures. ### Core example The following shows a reactive entity with a parent aggregate reference, a repository, entity-level and repository-level subscriptions, and a property mutation that fires events through both: ```kotlin // @PersistenceMapping: marks the entity for KSP codegen — generates Album_LirpTableDef // (the Exposed table) and accessors, mapping it to the "albums" table @PersistenceMapping(name = "albums") data class Album( override val id: Int, var title: String, // @Indexed: secondary index for O(1) equality lookups on genre @Indexed val genre: String, initialRating: Double = 0.0, initialArtistId: Int = 0 ) : ReactiveEntityBase() { var rating: Double by reactiveProperty(initialRating) // @ToOneAggregate: declares a single-entity FK reference on the persisted scalar; onDelete // sets the cascade mode and bubbleUp = true propagates child mutations to the album's subscribers. // KSP generates the extension accessor: val Album.artist: ReactiveEntityReference @ToOneAggregate(target = Artist::class, bubbleUp = true, onDelete = CascadeAction.DETACH) var artistId: Int by reactiveProperty(initialArtistId) override val uniqueId = "album-$id" override fun clone() = Album(id, title, genre, rating, artistId) } // @LirpRepository: registers the repository for context wiring and create()-factory codegen @LirpRepository class AlbumRepository : VolatileRepository("Albums") { fun create(id: Int, title: String, genre: String, artistId: Int): Album = Album(id, title, genre, initialArtistId = artistId).also { add(it) } } val repo = AlbumRepository() // Repository-level: track all structural changes (synchronous — runs inline on the calling thread) repo.subscribe { event -> when (event) { is StandardCrudEvent.Create -> println("Added: ${event.entities.values.first().title}") is StandardCrudEvent.Update -> println("Updated album") is StandardCrudEvent.Delete -> println("Removed album") else -> {} } } val nevermind = repo.create(1, "Nevermind", "rock", 42) // Entity-level: fine-grained property change tracking (synchronous) nevermind.subscribe { event -> if (event is PropertyChanged<*, *, *>) println("${event.property.name}: ${event.oldValue} -> ${event.newValue}") } nevermind.rating = 9.5 // fires both entity subscriber and repository Update event // For slow or remote work, use subscribeAsync — delivers on a coroutine, does not block the emitting thread repo.subscribeAsync { event -> if (event is StandardCrudEvent.Create) { // e.g. update a remote search index, send a notification — safe here because we're on a coroutine println("Async: album added to search index: ${event.entities.values.first().title}") } } ``` See [Core Concepts](https://github.com/octaviospain/lirp/wiki/Core-Concepts) for the reactive-property model, subscription patterns, and entity lifecycle. ### Synchronous vs asynchronous subscriptions LIRP offers two subscription transports: | Transport | Method | Delivery | Use when | |-----------|--------|----------|----------| | **Synchronous** | `subscribe { }` | Inline on the emitting thread, before `emitAsync` returns | Fast in-process work: cache updates, audit appends, in-memory index maintenance — anything that must be visible by the time the mutation returns | | **Asynchronous** | `subscribeAsync { }` | On a coroutine dispatcher, after `emitAsync` returns | Slow, blocking, or remote work (database writes, HTTP calls, fan-out); anything that must not block the mutation path | ```kotlin // Sync — runs immediately on the thread that mutates the entity entity.subscribe { event -> localIndex.update(event) // fast, must be consistent before the next read } // Async — runs on a coroutine after the mutation returns entity.subscribeAsync { event -> searchService.reindex(event.entity) // slow — safe to defer } ``` If a sync callback is slow or blocks, it delays the emitting thread for every mutation. Use `subscribeAsync` for any work that takes more than a few microseconds or that calls I/O. ## SQL Persistence `SqlRepository` persists entities to a relational database automatically. Reads are served from the in-memory `ConcurrentHashMap` — `findById` never round-trips to the database. Writes are enqueued into a debounced pipeline (default 100 ms, max 1 s) and flushed on a background coroutine. See [SQL Persistence](https://github.com/octaviospain/lirp/wiki/SQL-Persistence) for write-path and flush details. ```kotlin @LirpRepository class AlbumRepository(dataSource: DataSource) : SqlRepository(dataSource, Album_LirpTableDef) // Or build the pool from a JDBC URL — the repository owns the HikariDataSource lifecycle @LirpRepository class AlbumRepository : SqlRepository("jdbc:postgresql://localhost:5432/mydb", Album_LirpTableDef) ``` ### Credential handling JDBC URLs frequently embed secrets. Treat them like passwords, not like configuration. Prefer injecting credentials via a pre-configured `DataSource` rather than embedding them in the URL string. For example, build a `HikariDataSource` from environment variables and pass it to the `SqlRepository(dataSource, tableDef)` constructor — credentials never appear inside the URL itself, and the calling code controls pool lifecycle: ```kotlin val config = HikariConfig().apply { jdbcUrl = System.getenv("JDBC_URL") ?: error("JDBC_URL not set") // inject user/password via config rather than the URL to avoid leaking them in logs setUsername(System.getenv("DB_USER")) setPassword(System.getenv("DB_PASSWORD")) } val repo = AlbumRepository(HikariDataSource(config)) ``` When you must include a URL in diagnostic output, route it through `ConnectionUrlSanitizer` to mask passwords before they reach logs: ```kotlin import net.transgressoft.lirp.persistence.sql.ConnectionUrlSanitizer logger.debug { "Connecting to: ${ConnectionUrlSanitizer.sanitize(jdbcUrl)}" } // jdbc:postgresql://user:secret@host:5432/db → jdbc:postgresql://user:****@host:5432/db ``` `sanitize` handles all five supported dialects, is case-insensitive on the `password` key, and returns malformed input verbatim — it never throws. See the [SQL Persistence wiki page](https://github.com/octaviospain/lirp/wiki/SQL-Persistence#credential-handling) for the full guidance. ## Query DSL LIRP provides a type-safe, Kotlin-native query DSL for filtering, ordering, and paginating entities directly from any `Repository`. Predicates compose with infix operators; the planner automatically routes indexed equality checks through secondary indexes and falls back to in-memory scans for range and composite predicates. ```kotlin // Equality filter — auto-routes through @Indexed if available val rockAlbums = repo.query { where { Album::genre eq "rock" } }.toList() // Composite predicates with AND, OR, NOT; ordering and pagination val topRated = repo.query { where { (Album::genre eq "rock") and (Album::rating gte 8.0) } orderBy(Album::rating, Direction.DESC) limit(10) }.toList() // Cross-aggregate via: albums whose artist is from a given country val britishAlbums = repo.query { where { Album::artistId via artists where { Artist::country eq "UK" } } }.toList() // Collection reference: playlists that include at least one top-rated track val featuredPlaylists = playlists.query { where { Playlist::trackIds via tracks anyMatch { Track::rating gt 9.0 } } }.toList() ``` The returned `Sequence` is lazy — no evaluation occurs until a terminal operation (`toList`, `firstOrNull`, `count`, etc.). See [Query DSL](https://github.com/octaviospain/lirp/wiki/Query-DSL) for the full operator reference, planner strategies, and Java interop notes. | Persistence target | Module | Status | |---|---|---| | PostgreSQL, MySQL, MariaDB | `lirp-sql` | Supported | | JSON file | `lirp-core` | Supported | | MS SQL Server, Oracle | `lirp-sql` | Not tested | Deep coverage of the write pipeline, collapse algorithm, transactional guarantees, `@Version` optimistic locking, aggregate references, cascade semantics, collection delegates, JSON persistence, and JavaFX integration lives on the wiki — see [Documentation](#documentation) below. ## Kafka Integration `lirp-kafka` publishes domain mutations to Kafka using the **transactional outbox** pattern: the outbox row and the entity row are written in the same JDBC commit, eliminating the dual-write / lost-event window. A background relay polls the outbox and delivers each row to the broker. ### Zero per-event wiring Extend `KafkaOutboxSqlRepository` instead of `SqlRepository`. Every create, update, and soft-delete co-inserts an outbox row in the same transaction that persists the entity — no manual event emission needed: ```kotlin @LirpRepository class VehicleRepository(dataSource: DataSource) : KafkaOutboxSqlRepository(dataSource, Vehicle_LirpTableDef) ``` ### Starting the relay Create a `LirpKafkaConfig` with the broker address, then call `startRelay` with the shared `DataSource`. The relay runs on a background coroutine and stops when `close` is called: ```kotlin val kafka = LirpKafkaConfig.create(bootstrapServers) kafka.startRelay(dataSource, KafkaOutboxConfig.DEFAULT) // ... kafka.close() // stops the relay and releases the broker connection ``` `KafkaOutboxConfig` exposes relay tuning knobs: `pollIntervalMs` (default 500 ms), `batchSize` (default 100), `maxRetries` (default 5), and exponential `retryBaseDelayMs` / `retryMaxDelayMs`. Pass `KafkaOutboxConfig.DEFAULT` or a custom instance to `startRelay`. ### Delivery semantics Publishing is **at-least-once**: if the relay crashes after sending a record but before marking it sent, the row is eligible for redelivery on the next polling cycle. Each Kafka record carries the aggregate id as the key (preserving per-aggregate ordering) and a `ce_id` CloudEvents header set to the outbox row's UUID — consumers use it as an idempotency key for dedup. Rows that exhaust all retries move to a dead-letter table. ### Supported store Only SQL-backed repositories participate in the transactional outbox. `JsonFileRepository` and `VolatileRepository` cannot guarantee atomic outbox writes and are explicitly unsupported. For architecture details, the serialization (`LirpEventSerializer` / CloudEvents default) and topic (`TopicResolver`) SPIs, redelivery behaviour, and the inbound-consumption DIY pattern see [Kafka Integration](https://github.com/octaviospain/lirp/wiki/Kafka-Integration) on the wiki. ## Features - **Transparent SQL persistence** — add an entity, change a property, the database stays in sync automatically - **Entity-first reactivity** — `var x by reactiveProperty(init)` notifies subscribers on assignment, zero overhead when unobserved - **Two subscription levels** — repository-level `CrudEvent`s and entity-level `MutationEvent`s - **DDD aggregate references** — cardinality-explicit annotations: `@ToOneAggregate` for single-entity refs — on the persisted FK scalar (KSP generates the navigation extension accessor) or on a `by aggregate { … }` / `by optionalAggregate { … }` delegate when the key is computed rather than a stored scalar; `@ToManyAggregates` on collection navigation properties (`aggregateList` / `aggregateSet`); and `polymorphicAggregate` for exactly-one-of-N typed references. The to-one/to-many split is compiler-enforced — `@ToOneAggregate` on a collection and `@ToManyAggregates` on a single reference are both rejected at build time. All four cascade modes (DETACH / CASCADE / RESTRICT / NONE) are enforced both app-side and at the database layer (FK constraints on scalar refs, junction tables for collection refs) - **JSON FK reconciliation** — `JsonFkPolicy.LOG_AND_RECONCILE` (default) silently repairs dangling refs at load; `JsonFkPolicy.STRICT` fails loudly — symmetric to SQL `ON DELETE RESTRICT` - **Soft delete** — `softDelete(entity)` marks an entity deleted by setting `deletedAt` without removing the row; all reads default-exclude soft-deleted entities. `restore(entity)` returns the entity to the active set. `includeDeleted()` and `onlyDeleted()` Query DSL verbs opt in to deleted visibility. Hard delete remains available via `remove()`. KSP auto-injects the `deleted_at` column for `SoftDeletable` entities - **Secondary indexes** — `@Indexed` for O(1) equality lookups - **Type-safe Query DSL** — Kotlin-native filtering, ordering, and pagination with automatic index routing - **Optimistic locking** — `@Version` triggers versioned UPDATE/DELETE; conflicts surface as `StandardCrudEvent.Conflict` with canonical state - **Convention-over-configuration KSP codegen** — `@PersistenceMapping` generates table definitions; annotations only when you need to customize. Generated companions match the entity's own visibility, so an `internal` entity produces compiling `internal` companions; an explicitly-annotated `private`/`protected` entity fails the build with a targeted diagnostic rather than uncompilable output - **Robust column eligibility** — `@PersistenceIgnore` and `@Transient` (both `kotlin.jvm` and `kotlinx.serialization`) exclude a property from the generated table. Property types still resolving during KSP's multi-round processing are deferred and retried rather than dropped, and a type that never resolves fails the build with a targeted diagnostic instead of a silently-missing column - **Custom column converters** — route non-scalar domain types through a consumer-supplied `ColumnConverter` `object` referenced via `@PersistenceProperty(converter = MyConverter::class)`. The contract lives in `lirp-api` - **Embeddable value objects** — `@Embeddable` + `@Embedded` flattens value-object fields into prefixed columns on the parent entity's table (no join, no extra table) - **Element collections** — `@ElementCollection` persists a `List` or `Set` of scalar-backed values as a single JSON-array `TEXT` column - **JSON persistence** — debounced file writes via `JsonFileRepository`. `LirpEntitySerializer` round-trips reactive entities with zero reflection when lirp-ksp is applied, and falls back to a reflection-based accessor (no `--add-opens` required) when an entity's module deliberately omits lirp-ksp — so JSON persistence works with or without code generation - **Repository-as-factory** — typed `create()` methods with automatic `@LirpRepository` registration - **JavaFX integration** (`lirp-fx`) — `fxAggregateList`/`fxAggregateSet` bridging lirp collections with `ObservableList`/`ObservableSet`, scalar delegates (`fxString`, `fxInteger`, etc.), read-only `ObservableMap` projections - **Non-FX projection maps** — `projection` (aggregate source) and `registryProjection` (whole-registry source) in `lirp-core` group entities into a `Map>` with no JavaFX dependency. An optional value-transform (`{ pk, items -> V }`) maps each bucket to a derived value, recomputed only for affected buckets, and a multi-key extractor (`multiKeyProjection`) places one entity in every bucket its key set yields. Registry-backed projections are `AutoCloseable` - **Kafka event publishing** (`lirp-kafka`) — domain mutations are published to Kafka through a transactional outbox with zero per-event wiring; a repository extends `KafkaOutboxSqlRepository` and a background relay delivers each committed change at-least-once to a Kafka topic - **Full Java interoperability** ## Limitations and Design Trade-offs LIRP's in-memory-first architecture has trade-offs that influence where it fits best: - **Full dataset loaded into memory** — `SqlRepository` and `JsonFileRepository` load every row into a `ConcurrentHashMap` on initialization. This enables instant reads and O(1) indexed lookups but caps practical dataset size at what the JVM heap can hold (comfortable up to low thousands of entities; tens of thousands need heap tuning; hundreds of thousands are impractical). - **Optimistic writes, eventual persistence** — in-memory state is always authoritative; a crash between enqueue and flush loses uncommitted mutations. The debounce window (default 100 ms, max 1 s) defines the data-loss window. - **Single-node only** — no cross-process replication or distributed cache invalidation. LIRP is not a substitute for a shared database layer in a multi-instance deployment. - **No joins or SQL aggregations** — the Query DSL operates on a single repository at a time. Cross-repository joins, GROUP BY, or window functions should be handled at the SQL level outside LIRP. **Best suited for:** microservices or bounded contexts with small-to-medium datasets where domain reactivity and transparent persistence matter more than raw query power — configuration stores, user preference services, catalog management, any context where the working set fits comfortably in memory. **Not suited for:** analytics workloads, high-cardinality datasets, or services requiring cross-instance consistency. ## Performance Benchmarks run with JMH 1.37 on OpenJDK 21.0.10, 13th Gen Intel Core i7-13700, 62 GB RAM. Repository benchmarks use H2 in-memory databases with per-trial isolation. Highlights at 10,000 entities: | Repository | `add()` throughput | `findById()` p50 | |---|---|---| | `VolatileRepository` | 271,877 ops/s | 27 ns | | `SqlRepository` | 92,151 ops/s | 27 ns | | `JsonFileRepository` | 97,720 ops/s | 27 ns | `findById()` at 27 ns is against the in-memory `ConcurrentHashMap` — the SQL and JSON repositories skip the round-trip entirely. For operation-level persistence timing details and full benchmark methodology, see [Performance Benchmarks](https://github.com/octaviospain/lirp/wiki/Performance-Benchmarks). **JDK 25 footprint tip:** applications running on JDK 25 can reduce LIRP's resident memory by ~11% by enabling [`-XX:+UseCompactObjectHeaders`](https://openjdk.org/jeps/519) on their own JVM. The flag shrinks each object header from 12 to 8 bytes (JEP 519, product flag — no unlock needed); because LIRP keeps the full working set in a `ConcurrentHashMap`, the saving scales with entity count. The flag is incompatible with ZGC on x64; G1 (the default collector) works correctly with it. ## Logging LIRP uses [SLF4J](https://www.slf4j.org/) via [kotlin-logging](https://github.com/oshai/kotlin-logging) and ships **no logging configuration in its published jars**. Add your preferred SLF4J binding (`logback-classic`, `log4j-slf4j2-impl`, etc.) to your application; LIRP will use it automatically. ### Log level policy | Level | What LIRP logs at that level | |-------|------------------------------| | TRACE | Per-mutation / per-entity events — property changes, flush triggers, strategy dispatch | | DEBUG | Lifecycle events — repository init/close, entity add/remove, subscribe/unsubscribe, FK installation, flush counts | | WARN | Recoverable issues — cascade failures, buffer overflow, conflict detected, FK reconciliation, retry attempts | | ERROR | Permanent failures and invariant breaches — serialisation errors, unrecoverable recovery, uncaught async exceptions | ### MDC keys When structured logging is enabled (SLF4J MDC + `kotlinx-coroutines-slf4j`), LIRP populates the following keys for the duration of async operations so log lines carry full context: | MDC key | Value | |---------|-------| | `lirp.repository` | Repository name (e.g. `AlbumRepository`) | | `lirp.operation` | Operation name (e.g. `FLUSH`, `EMIT`) | The keys are set before each async launch and removed in a `finally` block, so they do not leak into unrelated coroutines. ### Error handler Repositories accept an optional `onError: LirpErrorHandler?` constructor parameter. When set, the handler is invoked after the existing error log whenever an async operation catches an exception. The framework logs first; the handler observes but does not alter control flow. ```kotlin val repo = SqlRepository( dataSource, Album_LirpTableDef, onError = LirpErrorHandler { throwable, ctx -> metrics.increment("lirp.flush.error", "repo" to ctx.repository) alerting.notify("${ctx.operation} on ${ctx.repository} failed: ${throwable.message}") } ) ``` Individual subscriptions can also carry an independent error handler via `subscribeAsync(action, onError)`, independent of the repository-level handler. ## Upgrading from v3.2.0 The next release contains a **breaking change** in JSON deserialization. `LirpEntitySerializer` now fails fast (throws `IllegalStateException`) when a required `by reactiveProperty(...)` field is missing from persisted JSON, rather than silently defaulting it. Deployments that added a reactive property without migrating existing JSON files will see an exception on load. See **[CHANGELOG.md](CHANGELOG.md)** for the full migration guide and the two available migration paths (JSON file migration or converting to a default-valued constructor parameter). ## Upgrading to v3.2.0 Version 3.2.0 adds the optional `lirp-kafka` module for transactional-outbox Kafka publishing. All changes are additive and non-breaking — existing code requires no migration. Opt in by adding the `lirp-kafka` dependency; see the [Kafka Integration](https://github.com/octaviospain/lirp/wiki/Kafka-Integration) wiki page. ## Upgrading to v3.1.0 Version 3.1.0 adds first-class soft delete and removes two public API elements deprecated since 3.0.0. See **[CHANGELOG.md](CHANGELOG.md)** for the full migration guide, including: - **Soft delete** — `Repository.softDelete(entity)` / `restore(entity)`, default-exclude reads, `includeDeleted()` / `onlyDeleted()` Query DSL verbs, new `StandardCrudEvent.SoftDelete` / `Restore` events, and KSP-injected `deleted_at` column for `SoftDeletable` entities - **Breaking change** — `StandardCrudEvent.SoftDelete` and `Restore` are new sealed subtypes; exhaustive `when` expressions over `StandardCrudEvent` must add both branches (or an `else`) - `MutationEvent.Type.MUTATE(301)` removed — use `PROPERTY_CHANGED(302)` or `BATCH_CHANGED(303)` - `ReactiveMutationEvent` class removed — use `PropertyChanged` or `BatchChanged` instead - Aggregate bubble-up events now report type 302 or 303 (derived from the child event) instead of 301 ## Upgrading to v3.0.0 Version 3.0.0 contains breaking changes to the aggregate annotation vocabulary, the subscription API, and the mutation event model. See **[CHANGELOG.md](CHANGELOG.md)** for the full migration guide, including: - The single-entity aggregate annotation is replaced by `@ToOneAggregate`, placed on the FK scalar (navigation is then a KSP-generated extension accessor requiring an explicit import) or, for a computed key, on the retained `by aggregate { … }` / `by optionalAggregate { … }` delegate - The collection aggregate annotation is renamed to `@ToManyAggregates` (parameters unchanged) and is now collection-only — a single reference annotated with it fails the build with guidance to use `@ToOneAggregate` - `subscribe` is now **synchronous** by default — rename all former async `subscribe` call sites to `subscribeAsync` - `changes` access arms the replay buffer lazily — touch `publisher.changes` at boot if replay buffering from startup is needed - `PropertyChanged` / `BatchChanged` replace `oldEntity` / `newEntity` on mutation events - Core and FX projection map imports moved to `…persistence.projection` subpackages ## Documentation The **[LIRP Wiki](https://github.com/octaviospain/lirp/wiki)** is the canonical reference. Start with the page that matches your question: | Page | What's there | |---|---| | [Home](https://github.com/octaviospain/lirp/wiki) | Guided tour, entry points by use case | | [Consuming LIRP](https://github.com/octaviospain/lirp/wiki/Consuming-LIRP) | External-consumer setup: Gradle plugin, Gradle manual, Maven, compatibility matrix, KSP troubleshooting | | [Core Concepts](https://github.com/octaviospain/lirp/wiki/Core-Concepts) | Reactive entities, `reactiveProperty()`, lazy publishers, events, subscription patterns, `withEventsDisabled` | | [Query DSL](https://github.com/octaviospain/lirp/wiki/Query-DSL) | Type-safe, Kotlin-native query DSL for filtering, ordering, and paginating entities | | [DDD & Aggregates](https://github.com/octaviospain/lirp/wiki/DDD-and-Aggregates) | `@ToOneAggregate`, `@ToManyAggregates`, `polymorphicAggregate`, collection delegates, cascade, bubble-up, `CollectionChangeEvent`, app-side ↔ SQL FK mapping | | [Persistence](https://github.com/octaviospain/lirp/wiki/Persistence) | Repository hierarchy, `PersistentRepositoryBase`, debounced write pipeline, deferred loading | | [SQL Persistence](https://github.com/octaviospain/lirp/wiki/SQL-Persistence) | `SqlRepository`, entity annotations, type mapping, dialect support, batch SQL, foreign keys & junction tables, deferred FK installation | | [SQL Mappings](https://github.com/octaviospain/lirp/wiki/SQL-Mappings) | Column types, custom converters, embeddable value objects, element collections, `@PersistenceCreator`, `@Indexed`, UUID storage | | [SQL Schema & Relationships](https://github.com/octaviospain/lirp/wiki/SQL-Schema-and-Relationships) | Auto table creation, foreign keys & junction tables, `SqlTableDef` capability interfaces, dialect notes | | [Transactional Boundaries](https://github.com/octaviospain/lirp/wiki/Transactional-Boundaries) | Single-aggregate atomicity, `@Version` optimistic locking, `Conflict` event, saga/compensation pattern | | [Kafka Integration](https://github.com/octaviospain/lirp/wiki/Kafka-Integration) | `lirp-kafka` architecture (mutation → outbox → relay → Kafka → consumer), setup, the serialization + topic SPIs, delivery semantics (at-least-once, idempotency key, redelivery, dead-letter), and the inbound-consumption DIY pattern | | [Soft Delete](https://github.com/octaviospain/lirp/wiki/Soft-Delete) | Reversible deletes, `SoftDelete`/`Restore` events, cross-aggregate `via()` visibility | | [JSON Persistence](https://github.com/octaviospain/lirp/wiki/JSON-Persistence) | `JsonFileRepository`, `LirpEntitySerializer`, polymorphic serializers, deferred loading, `JsonFkPolicy` reconciliation | | [JavaFX Integration](https://github.com/octaviospain/lirp/wiki/JavaFX-Integration) | `lirp-fx`, `fxAggregateList`/`fxAggregateSet`, scalar delegates, dual notification, FX thread dispatch | | [Projection Maps](https://github.com/octaviospain/lirp/wiki/Projection-Maps) | `projection`/`registryProjection` and `fxProjection`/`registryFxProjection` — read-only grouped views, with value-transform and multi-key variants | | [Java Interop](https://github.com/octaviospain/lirp/wiki/Java-Interop) | Full Java examples for entities, repositories, subscriptions, collection events | | [Desktop & Embedded Setup](https://github.com/octaviospain/lirp/wiki/Desktop-Embedded-Setup) | SQLite configuration walkthrough for desktop and embedded deployments | | [Performance Benchmarks](https://github.com/octaviospain/lirp/wiki/Performance-Benchmarks) | JMH throughput and latency numbers across backends and operations | | [Architecture Overview](https://github.com/octaviospain/lirp/wiki/Architecture-Overview) | Entity hierarchy, event flow, module dependency, repository lifecycle diagrams | ### Module overview | Module | Role | |---|---| | `lirp-api` | Pure interfaces & contracts: `ReactiveEntity`, `Repository`, event types, annotations (`@PersistenceMapping`, `@ToOneAggregate`, `@ToManyAggregates`, `@Indexed`, `@Version`, `@LirpRepository`). No implementation. | | `lirp-core` | Reactive entity machinery (`ReactiveEntityBase`, `reactiveProperty`), `VolatileRepository`, `JsonFileRepository`, `LirpEntitySerializer`, `projection`, `LirpContext`, debounced write pipeline. | | `lirp-ksp` | KSP processor generating per-entity `_LirpTableDef`, `LirpFxScalarAccessor`, repository registration. Drives convention-over-configuration codegen. | | `lirp-sql-api` | Pure SQL contracts: `SqlTableDef`, `JunctionAware`, `ForeignKeyAware`, `VersionedTableDef`. Sits between `lirp-api` and `lirp-sql` — no implementation, no Exposed/HikariCP dependency. | | `lirp-sql` | `SqlRepository` built on JetBrains Exposed + HikariCP. Supports PostgreSQL/MySQL/MariaDB/SQLite (MS SQL/Oracle untested). | | `lirp-fx` | JavaFX bridge: `fxAggregateList`/`fxAggregateSet`, scalar delegates (`fxString`, `fxInteger`...), read-only `ObservableMap` projections, FX thread dispatch. | | `lirp-kafka` | Kafka event publishing via the transactional outbox — `KafkaOutboxSqlRepository` (co-inserts an outbox row in the same JDBC commit as the entity), `LirpKafkaConfig` / `KafkaOutboxConfig` (background relay), `KafkaEventPublisher`, and pluggable `LirpEventSerializer` (CloudEvents default) / `TopicResolver` SPIs. Builds on `lirp-sql`. | | `lirp-gradle-plugin` | `net.transgressoft.lirp.sql` Gradle plugin auto-configuring KSP for consumers. | | `lirp-benchmark` | JMH benchmarks. | ## Contributing Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. ## License and Attributions Copyright (c) 2025 Octavio Calleya García. LIRP is free software under the [GNU GPL v3 license](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text). This project uses: - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) for asynchronous programming - [Kotlin Serialization](https://github.com/Kotlin/kotlinx.serialization) for JSON processing - [JetBrains Exposed](https://github.com/JetBrains/Exposed) for SQL generation and type-safe query building - [HikariCP](https://github.com/brettwooldridge/HikariCP) for JDBC connection pooling - [Kotest](https://kotest.io/) for testing The approach is inspired by books including [Object Thinking by David West](https://www.goodreads.com/book/show/43940.Object_Thinking), [Domain-Driven Design: Aligning Software Architecture and Business Strategy by Vladik Khonon](https://www.goodreads.com/book/show/57573212-learning-domain-driven-design) and [Elegant Objects by Yegor Bugayenko](https://www.yegor256.com/elegant-objects.html).