epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:sessions"))
|
||||
implementation "org.xerial:sqlite-jdbc"
|
||||
testImplementation(testFixtures(project(":testing:contracts")))
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.correx.infrastructure.persistence
|
||||
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
class InMemoryEventStore : EventStore {
|
||||
private val streams = ConcurrentHashMap<SessionId, MutableList<StoredEvent>>()
|
||||
private val sequences = ConcurrentHashMap<SessionId, AtomicLong>()
|
||||
private val seenEventIds = ConcurrentHashMap.newKeySet<EventId>()
|
||||
|
||||
override fun append(event: NewEvent): StoredEvent {
|
||||
val stream = streams.computeIfAbsent(event.metadata.sessionId) { mutableListOf() }
|
||||
|
||||
synchronized(stream) {
|
||||
if (!seenEventIds.add(event.metadata.eventId)) {
|
||||
error("duplicate event_id: ${event.metadata.eventId}")
|
||||
}
|
||||
return doAppend(event, stream)
|
||||
}
|
||||
}
|
||||
|
||||
override fun appendAll(events: List<NewEvent>): List<StoredEvent> {
|
||||
if (events.isEmpty()) return emptyList()
|
||||
|
||||
val sessionId = events.first().metadata.sessionId
|
||||
val stream = streams.computeIfAbsent(sessionId) { mutableListOf() }
|
||||
|
||||
synchronized(stream) {
|
||||
return events.map { doAppend(it, stream) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> {
|
||||
return streams[sessionId]
|
||||
?.sortedBy { it.sequence }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> {
|
||||
return streams[sessionId]
|
||||
?.filter { it.sequence > fromSequence }
|
||||
?.toList()
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun lastSequence(sessionId: SessionId): Long? {
|
||||
return sequences[sessionId]?.get()
|
||||
}
|
||||
|
||||
private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent {
|
||||
val seq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) }
|
||||
.incrementAndGet()
|
||||
|
||||
val stored = StoredEvent(metadata = event.metadata, sequence = seq, payload = event.payload)
|
||||
// safety: enforce ordering invariant
|
||||
if (stream.isNotEmpty() && stream.last().sequence >= stored.sequence) {
|
||||
error("sequence violation for session ${event.metadata.sessionId}")
|
||||
}
|
||||
stream.add(stored)
|
||||
|
||||
return stored
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package com.correx.infrastructure.persistence
|
||||
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.serialization.JsonEventSerializer
|
||||
import com.correx.core.events.serialization.eventJson
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.CausationId
|
||||
import com.correx.core.events.types.CorrelationId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.infrastructure.persistence.util.JDBCHelper.transaction
|
||||
import kotlinx.datetime.Instant
|
||||
import java.sql.Connection
|
||||
import java.sql.ResultSet
|
||||
|
||||
class SqliteEventStore(
|
||||
private val connection: Connection,
|
||||
private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson)
|
||||
) : EventStore {
|
||||
|
||||
init {
|
||||
connection.createStatement().use { stmt ->
|
||||
stmt.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
causation_id TEXT,
|
||||
correlation_id TEXT,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun append(event: NewEvent): StoredEvent {
|
||||
return connection.transaction {
|
||||
val existing = findByEventId(event.metadata.eventId)
|
||||
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
||||
|
||||
val seq = nextSequence(event.metadata.sessionId)
|
||||
|
||||
val stored = StoredEvent(
|
||||
metadata = event.metadata,
|
||||
sequence = seq,
|
||||
payload = event.payload
|
||||
)
|
||||
|
||||
insert(stored)
|
||||
|
||||
stored
|
||||
}
|
||||
}
|
||||
|
||||
override fun appendAll(events: List<NewEvent>): List<StoredEvent> {
|
||||
if (events.isEmpty()) return emptyList()
|
||||
|
||||
val sessionId = events.first().metadata.sessionId
|
||||
|
||||
return connection.transaction {
|
||||
events.map { event ->
|
||||
val existing = findByEventId(event.metadata.eventId)
|
||||
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
||||
|
||||
val seq = nextSequence(sessionId)
|
||||
|
||||
val stored = StoredEvent(
|
||||
metadata = event.metadata,
|
||||
sequence = seq,
|
||||
payload = event.payload
|
||||
)
|
||||
|
||||
insert(stored)
|
||||
|
||||
stored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT * FROM events
|
||||
WHERE session_id = ?
|
||||
ORDER BY sequence ASC
|
||||
""".trimIndent()
|
||||
).use { ps ->
|
||||
ps.setString(1, sessionId.value)
|
||||
|
||||
ps.executeQuery().use { rs ->
|
||||
buildList {
|
||||
while (rs.next()) {
|
||||
add(map(rs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT * FROM events
|
||||
WHERE session_id = ?
|
||||
AND sequence > ?
|
||||
ORDER BY sequence ASC
|
||||
""".trimIndent()
|
||||
).use { ps ->
|
||||
ps.setString(1, sessionId.value)
|
||||
ps.setLong(2, fromSequence)
|
||||
|
||||
ps.executeQuery().use { rs ->
|
||||
buildList {
|
||||
while (rs.next()) {
|
||||
add(map(rs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun lastSequence(sessionId: SessionId): Long? =
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT MAX(sequence)
|
||||
FROM events
|
||||
WHERE session_id = ?
|
||||
""".trimIndent()
|
||||
).use { ps ->
|
||||
ps.setString(1, sessionId.value)
|
||||
|
||||
ps.executeQuery().use { rs ->
|
||||
if (rs.next()) rs.getLong(1).takeIf { !rs.wasNull() }
|
||||
else null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private fun nextSequence(sessionId: SessionId): Long =
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT COALESCE(MAX(sequence), 0)
|
||||
FROM events
|
||||
WHERE session_id = ?
|
||||
""".trimIndent()
|
||||
).use { ps ->
|
||||
ps.setString(1, sessionId.value)
|
||||
ps.executeQuery().use { rs ->
|
||||
rs.next()
|
||||
rs.getLong(1) + 1
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun insert(event: StoredEvent) {
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO events (
|
||||
event_id,
|
||||
session_id,
|
||||
sequence,
|
||||
timestamp,
|
||||
schema_version,
|
||||
causation_id,
|
||||
correlation_id,
|
||||
payload
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).use { ps ->
|
||||
ps.setString(1, event.metadata.eventId.value)
|
||||
ps.setString(2, event.metadata.sessionId.value)
|
||||
ps.setLong(3, event.sequence)
|
||||
ps.setString(4, event.metadata.timestamp.toString())
|
||||
ps.setInt(5, event.metadata.schemaVersion)
|
||||
ps.setString(6, event.metadata.causationId?.value)
|
||||
ps.setString(7, event.metadata.correlationId?.value)
|
||||
ps.setString(8, jsonSerializer.serialize(event.payload))
|
||||
|
||||
ps.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findByEventId(eventId: EventId): StoredEvent? =
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT * FROM events
|
||||
WHERE event_id = ?
|
||||
""".trimIndent()
|
||||
).use { ps ->
|
||||
ps.setString(1, eventId.value)
|
||||
|
||||
ps.executeQuery().use { rs ->
|
||||
if (rs.next()) map(rs) else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun map(rs: ResultSet): StoredEvent =
|
||||
StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(rs.getString("event_id")),
|
||||
sessionId = SessionId(rs.getString("session_id")),
|
||||
timestamp = Instant.parse(rs.getString("timestamp")),
|
||||
schemaVersion = rs.getInt("schema_version"),
|
||||
causationId = rs.getString("causation_id")?.let { CausationId(it) },
|
||||
correlationId = rs.getString("correlation_id")?.let { CorrelationId(it) }
|
||||
),
|
||||
sequence = rs.getLong("sequence"),
|
||||
payload = jsonSerializer.deserialize(rs.getString("payload"))
|
||||
)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.correx.infrastructure.persistence.util
|
||||
|
||||
import java.sql.Connection
|
||||
import java.sql.SQLException
|
||||
|
||||
object JDBCHelper {
|
||||
inline fun <T> Connection.transaction(block: () -> T): T {
|
||||
val oldAutoCommit = autoCommit
|
||||
try {
|
||||
autoCommit = false
|
||||
|
||||
val result = block()
|
||||
|
||||
commit()
|
||||
return result
|
||||
} catch (e: SQLException) {
|
||||
rollback()
|
||||
throw e
|
||||
} finally {
|
||||
autoCommit = oldAutoCommit
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.correx.infrastructure.persistence
|
||||
|
||||
import com.correx.testing.contracts.fixtures.events.store.EventStoreContractTest
|
||||
import com.correx.core.events.stores.EventStore
|
||||
|
||||
class InMemoryEventStoreTest : EventStoreContractTest() {
|
||||
override fun store(): EventStore = InMemoryEventStore()
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.correx.infrastructure.persistence
|
||||
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import com.correx.core.sessions.SessionCounterProjection
|
||||
import com.correx.core.sessions.SessionCounterState
|
||||
import com.correx.testing.contracts.fixtures.projections.SessionReplayContractTest
|
||||
|
||||
class InMemoryReplayTest : SessionReplayContractTest() {
|
||||
|
||||
override fun store(): EventStore =
|
||||
InMemoryEventStore()
|
||||
|
||||
override fun replayer(
|
||||
store: EventStore
|
||||
): EventReplayer<SessionCounterState> {
|
||||
return DefaultEventReplayer(
|
||||
store,
|
||||
SessionCounterProjection("s1")
|
||||
)
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.correx.infrastructure.persistence
|
||||
|
||||
import com.correx.testing.contracts.fixtures.events.store.EventStoreContractTest
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import java.sql.DriverManager
|
||||
|
||||
class SqliteEventStoreTest : EventStoreContractTest() {
|
||||
override fun store(): EventStore {
|
||||
val conn = DriverManager.getConnection("jdbc:sqlite::memory:")
|
||||
return SqliteEventStore(conn)
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.correx.infrastructure.persistence
|
||||
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import com.correx.core.sessions.SessionCounterProjection
|
||||
import com.correx.core.sessions.SessionCounterState
|
||||
import com.correx.testing.contracts.fixtures.projections.SessionReplayContractTest
|
||||
import java.sql.DriverManager
|
||||
|
||||
class SqliteReplayTest : SessionReplayContractTest() {
|
||||
|
||||
override fun store(): EventStore {
|
||||
val connection =
|
||||
DriverManager.getConnection("jdbc:sqlite::memory:")
|
||||
|
||||
return SqliteEventStore(connection)
|
||||
}
|
||||
|
||||
override fun replayer(
|
||||
store: EventStore
|
||||
): EventReplayer<SessionCounterState> {
|
||||
return DefaultEventReplayer(
|
||||
store,
|
||||
SessionCounterProjection("s1")
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user