From e66b0c09ff5cf544d813e9c7e260a7a7e16ee088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Fri, 7 Aug 2026 09:03:34 -0300 Subject: [PATCH 01/11] Remove legacy `Events` and `Utils` files, refactor `Note` to `ScrollEntry`, and update references across UI and tests --- README.md | 31 +++--- docs/api-concepts.md | 49 +++------- docs/console-showcase.md | 8 +- docs/getting-started.md | 60 ++++++------ docs/index.md | 5 +- docs/lifecycle-and-delivery.md | 15 ++- gradle.properties | 1 + gradle/libs.versions.toml | 16 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- .../kotlin/com/rafambn/scribe/Entry.kt | 13 +++ .../kotlin/com/rafambn/scribe/Events.kt | 41 -------- .../kotlin/com/rafambn/scribe/Scribe.kt | 29 +----- .../kotlin/com/rafambn/scribe/Scroll.kt | 9 +- .../kotlin/com/rafambn/scribe/Shelf.kt | 27 ++++-- .../kotlin/com/rafambn/scribe/Utils.kt | 10 -- .../kotlin/com/rafambn/scribe/CustomEntry.kt | 3 + .../scribe/ScribeConcurrencyAndScrollTest.kt | 41 ++++---- .../rafambn/scribe/ScribeContextMarginTest.kt | 28 +++--- .../scribe/ScribeDataSerializationTest.kt | 10 +- .../scribe/ScribeDeliveryRetireTest.kt | 95 +++++++++++++------ .../scribe/ScribeScrollLifecycleTest.kt | 32 +++---- .../com/rafambn/scribe/ScribeTestFixtures.kt | 40 +++----- testApp/README.md | 14 ++- testApp/shared/build.gradle.kts | 5 +- .../kotlin/scribe/demo/data/ConsoleRecord.kt | 71 ++++---------- .../kotlin/scribe/demo/scribe/AppScribe.kt | 14 +-- .../kotlin/scribe/demo/ui/HomeContent.kt | 16 ++-- .../kotlin/scribe/demo/ui/HomeScreen.kt | 4 +- .../kotlin/scribe/demo/ui/HomeViewModel.kt | 91 +++++++++--------- 29 files changed, 350 insertions(+), 430 deletions(-) create mode 100644 scribe/src/commonMain/kotlin/com/rafambn/scribe/Entry.kt delete mode 100644 scribe/src/commonMain/kotlin/com/rafambn/scribe/Events.kt delete mode 100644 scribe/src/commonMain/kotlin/com/rafambn/scribe/Utils.kt create mode 100644 scribe/src/commonTest/kotlin/com/rafambn/scribe/CustomEntry.kt diff --git a/README.md b/README.md index 69a33e3..f957ad1 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ ## Features: - Story-driven logging primitives instead of flat logger calls -- Single-event logging with `note(...)` and contextual logging with `newScroll(...)` -- Delivery hooks through `NoteSaver`, `ScrollSaver`, and `EntrySaver` +- Contextual logging with `newScroll(...)` and immediate-seal one-shot scrolls +- Delivery hooks through typed `Saver` instances and `EntrySaver` - Scroll lifecycle enrichment through `Margin` - Independent `Scribe` objects for applications and imported libraries @@ -44,7 +44,7 @@ Add Scribe to your `commonMain` dependencies: kotlin { sourceSets { commonMain.dependencies { - implementation("com.rafambn:scribe:0.4.0") + implementation("com.rafambn:scribe:0.5.0") } } } @@ -52,23 +52,23 @@ kotlin { ## Usage -Create a `Scribe` object, hire its runtime, and emit a note: +Create a `Scribe` object, hire its runtime, and emit a scroll: ```kotlin object AppScribe : Scribe() { override val shelves: List> = listOf( - NoteSaver { note -> - println("[${note.level}] ${note.tag}: ${note.message}") + Saver { scroll -> + println(scroll) } ) } AppScribe.hire(channel = Channel(capacity = 256)) -AppScribe.note( - tag = "payments", - message = "starting checkout", - level = Urgency.INFO, -) +val scroll = AppScribe.newScroll() +scroll["tag"] = JsonPrimitive("payments") +scroll["message"] = JsonPrimitive("starting checkout") +scroll["level"] = JsonPrimitive("INFO") +scroll.seal(AppScribe) ``` Use a scroll when you need shared context for a longer flow: @@ -76,7 +76,7 @@ Use a scroll when you need shared context for a longer flow: ```kotlin object BillingScribe : Scribe() { override val shelves: List> = listOf( - ScrollSaver { scroll -> println(scroll) } + Saver { scroll -> println(scroll) } ) override val imprint = mapOf( "service" to JsonPrimitive("billing"), @@ -89,15 +89,14 @@ val scroll = BillingScribe.newScroll(id = "checkout-42") scroll["gateway"] = JsonPrimitive("stripe") scroll["attempt"] = JsonPrimitive(1) scroll["retry"] = JsonPrimitive(false) -scroll.seal(BillingScribe, success = true) +scroll.seal(BillingScribe) ``` -Each `Scribe` object has independent configuration and delivery lifecycle. A `Scroll` is a mutable JSON-element map initialized by `newScroll(...)`; pass the runtime that should enrich and deliver it to `scroll.seal(scribe, ...)`. Each `seal(...)` call emits a separate `SealedScroll` snapshot. +Each `Scribe` object has independent configuration and delivery lifecycle. A `Scroll` is a mutable JSON-element map initialized by `newScroll(...)`; pass the runtime that should enrich and deliver it to `scroll.seal(scribe)`. Each `seal(...)` call emits a separate snapshot of the scroll data. Choose the saver that matches your output flow: ```kotlin -val noteSaver = NoteSaver { note -> println(note) } -val scrollSaver = ScrollSaver { scroll -> println(scroll) } +val scrollSaver = Saver { scroll -> println(scroll) } val entrySaver = EntrySaver { record -> println(record) } ``` diff --git a/docs/api-concepts.md b/docs/api-concepts.md index 2b5ac50..f7a0169 100644 --- a/docs/api-concepts.md +++ b/docs/api-concepts.md @@ -2,19 +2,17 @@ ## Core Types -Scribe models logging with two event shapes: +Scribe models logging with typed entries: -- `Note`: a single standalone event -- `SealedScroll`: a sealed snapshot result of a multi-step `Scroll` - -Both implement the sealed `Entry` interface, which is what `EntrySaver` receives. +- `Scroll`: a mutable JSON-map you build up and then pass to `seal(...)` +- `Entry`: the open base interface for every payload sent through a runtime +- `ScrollEntry`: the immutable map snapshot produced by sealing a `Scroll` ## Terminology -- `note(...)`: emits a single log entry through the active runtime - `newScroll(...)`: starts a contextual logging session -- `seal(scribe, ...)`: applies the supplied runtime's footer, snapshots the - current scroll data, and emits a `SealedScroll` +- `seal(scribe)`: applies the supplied runtime's footer, snapshots the + current scroll data, and emits a `ScrollEntry` - `extend(scroll)`: copies missing keys from another scroll into this one - `append(key, scroll)`: nests a scroll as a JSON object under the given key - `Margin`: hook for writing fields at open/close boundaries @@ -83,8 +81,8 @@ println(scroll.id) // "checkout-42" ``` Calling `seal(...)` more than once is allowed. Each call emits a separate -`SealedScroll` through the `Scribe` passed to that call, with the current -`success` value and a snapshot of the data at that point. +`ScrollEntry` through the `Scribe` passed to that call, with a snapshot of the data +at that point. ## `Scroll` Operations @@ -117,7 +115,7 @@ checkout.append("cart", meta) `Margin` enriches a scroll at beginning and end. ```kotlin -val timingMargin = object : Margin { +val margin = object : Margin { override fun header(scroll: Scroll) { scroll["started_at"] = JsonPrimitive(1000) } @@ -158,39 +156,18 @@ CheckoutScribe.hire( ## Event Shapes -```kotlin -Note( - tag = "payments", - message = "starting checkout", - level = Urgency.INFO, - timestamp = 1710000000000L, -) -``` +The standard delivered event is a sealed `Scroll` snapshot. Fields written to +the scroll via normal map operations appear directly in the delivered `ScrollEntry`: ```kotlin -SealedScroll( - success = true, - data = mapOf( +ScrollEntry( + mapOf( "scroll_id" to JsonPrimitive("checkout-42"), "gateway" to JsonPrimitive("stripe"), ), ) ``` -## Urgency Levels - -`Urgency` is used by `Note` to indicate severity: - -```kotlin -enum class Urgency { - VERBOSE, - DEBUG, - INFO, - WARN, - ERROR -} -``` - ## Failure Handling ```kotlin diff --git a/docs/console-showcase.md b/docs/console-showcase.md index 462e9ae..874e592 100644 --- a/docs/console-showcase.md +++ b/docs/console-showcase.md @@ -6,12 +6,12 @@ application console. There is no external service to configure. ## What The Showcase Demonstrates -- `note(...)` +- Quick scrolls: immediately sealed one-shot events - `newScroll(...)` with generated and custom IDs - Direct `Scroll` map-like writes and explicit delivery runtime selection - `extend(scroll)` and `append(key, scroll)` - Map read/remove operations -- `seal(...)` success and failure outcomes +- `seal(...)` snapshots and fail-styled scrolls (via data fields) - `Margin` - `EntrySaver` - Channel overflow behavior through `DROP_OLDEST` @@ -32,10 +32,10 @@ records are available through their platform run consoles. 1. Run `Checkout flow` and inspect a wide-event JSON record. 2. Run `Map read/remove` to observe mutation before sealing. -3. Run `Margins + seal(failure)` and verify timing fields plus `success = false`. +3. Run `Margins + seal(failure)` and verify timing fields plus the `failure_reason`/`success=false` data markers. 4. Run `JSON object serialization` to inspect a nested payload. 5. Run `String template message` to inspect the `message` and `order_id` fields. -6. Run `EntrySaver mixed flow` to print a note and a scroll through one saver. +6. Run `EntrySaver mixed flow` to print two scroll shapes through one saver. 7. Run `Overflow demo` and observe that a burst can be trimmed under pressure. 8. Run `Saver failure demo` and observe the printed saver error while delivery continues. 9. Compare `retire() (light queue)` with `retire() with backlog`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 6d16b27..e556b8e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,7 +8,7 @@ Use the library from shared code in your Kotlin Multiplatform module: kotlin { sourceSets { commonMain.dependencies { - implementation("com.rafambn:scribe:0.4.0") + implementation("com.rafambn:scribe:0.5.0") } } } @@ -21,8 +21,8 @@ object's runtime with a `Channel`. ```kotlin object AppScribe : Scribe() { - override val shelves: List> = listOf(NoteSaver { note -> - println("[${note.level}] ${note.tag}: ${note.message}") + override val shelves: List> = listOf(Saver { scroll -> + println(scroll) }) } @@ -36,28 +36,29 @@ AppScribe.hire( ## Emit a Single Event -Use `note(...)` for standalone events: +Every event is a scroll. For a standalone event, build a scroll and seal it +immediately: ```kotlin -AppScribe.note( - tag = "payments", - message = "starting checkout", - level = Urgency.INFO, -) +val scroll = AppScribe.newScroll() +scroll["tag"] = JsonPrimitive("payments") +scroll["message"] = JsonPrimitive("starting checkout") +scroll["level"] = JsonPrimitive("INFO") +scroll.seal(AppScribe) ``` With the saver above, the log output looks like this: ```text -[INFO] payments: starting checkout +{scroll_id=..., tag=payments, message=starting checkout, level=INFO} ``` ## Track a Flow with `Scroll` `Scroll` is a mutable map of JSON elements initialized by `newScroll(...)`. When sealing it, supply the `Scribe` runtime that should apply its footer -margin and deliver the event. Each `seal(...)` call emits a new -`SealedScroll` using a snapshot of the scroll data at that moment. +margin and deliver the event. Each `seal(...)` call emits a new snapshot of +the scroll data at that moment. You can also merge other scrolls or nest them: @@ -82,7 +83,7 @@ scroll["cart"] = Json.encodeToJsonElement( CheckoutMeta.serializer(), CheckoutMeta(itemCount = 3, subtotalCents = 249_900, featureFlag = "wide-events"), ) -scroll.seal(AppScribe, success = true) +scroll.seal(AppScribe) ``` ## Use Multiple Runtimes @@ -105,21 +106,18 @@ AnalyticsScribe.hire(channel = Channel(256)) Retiring `PaymentsScribe` does not stop `AnalyticsScribe`. -The emitted `SealedScroll` shape: +The emitted event shape is the scroll map itself: ```json { - "success": true, - "data": { - "scroll_id": "checkout-42", - "gateway": "stripe", - "attempt": 1, - "retry": false, - "cart": { - "item_count": 3, - "subtotal_cents": 249900, - "feature_flag": "wide-events" - } + "scroll_id": "checkout-42", + "gateway": "stripe", + "attempt": 1, + "retry": false, + "cart": { + "item_count": 3, + "subtotal_cents": 249900, + "feature_flag": "wide-events" } } ``` @@ -127,14 +125,16 @@ The emitted `SealedScroll` shape: ## Choose the Right Saver ```kotlin -val noteSaver = NoteSaver { note -> println(note) } -val scrollSaver = ScrollSaver { scroll -> println(scroll) } +val scrollSaver = Saver { scroll -> println(scroll) } val entrySaver = EntrySaver { entry -> println(entry) } + +data class AuditEntry(val message: String) : Entry +val auditSaver = Saver { audit -> println(audit.message) } ``` -- `NoteSaver` handles only `Note` -- `ScrollSaver` handles only `SealedScroll` -- `EntrySaver` handles both +- `Saver` handles scroll snapshots +- `Saver` handles entries whose runtime type is exactly `T` +- `EntrySaver` is the wildcard and handles every entry from the runtime ## What to Read Next diff --git a/docs/index.md b/docs/index.md index 3389dac..b9394de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,10 +26,9 @@ A Kotlin Multiplatform logging library for structured events and long-lived cont Modern systems do not fail in one place. A single user action can cross services, queues, retries, feature flags, and third-party calls. Traditional logging turns that into dozens of partial lines, each easy to write and hard to query. -Scribe is built around the argument from [loggingsucks.com](https://loggingsucks.com/): useful logs are not just structured, they are context-rich. Instead of scattering breadcrumbs everywhere, Scribe gives you primitives for both shapes you actually need: +Scribe is built around the argument from [loggingsucks.com](https://loggingsucks.com/): useful logs are not just structured, they are context-rich. Instead of scattering breadcrumbs everywhere, Scribe gives you one primitive for the shape you actually need: -- `Note` for one-off events that stand on their own (Old way, if you still insist) -- `Scroll` for building a wide event over the lifetime of an operation, then sealing it as a `SealedScroll` +- `Scroll` for building a wide event over the lifetime of an operation, then sealing it as a delivered `Entry` snapshot That pushes logging toward "what happened to this request or workflow?" instead of "what line of code ran next?". The result is fewer events, better context, and logs that are easier to filter, correlate, and analyze. diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index 93e0c63..0519a20 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -38,19 +38,18 @@ CheckoutScribe.hire( ## Emission APIs -Current emission calls are non-suspending: +Current emission calls are non-suspending and always produce scroll events: -- `note(...)` sends a `Note` - `seal(scribe, ...)` applies that runtime's footer margin, snapshots the - current `Scroll` data, and sends a `SealedScroll` + current `Scroll` data, and sends the resulting `Entry` -Both calls attempt an immediate channel send and block the calling thread if a +Calls attempt an immediate channel send and block the calling thread if a channel configured with `BufferOverflow.SUSPEND` is full. `Saver.write(...)` and `retire()` are the suspending parts of the API. There are no separate best-effort emission APIs in this runtime shape. Multiple calls to `seal(...)` on the same `Scroll` are intentional. Each call -emits a separate `SealedScroll` through the `Scribe` passed to that call. +emits a separate `Entry` through the `Scribe` passed to that call. ## Shared Context with `imprint` @@ -58,7 +57,7 @@ emits a separate `SealedScroll` through the `Scribe` passed to that call. ```kotlin object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(ScrollSaver { println(it) }) + override val shelves: List> = listOf(Saver { println(it) }) override val imprint = mapOf( "app" to JsonPrimitive("checkout"), "region" to JsonPrimitive("us-east-1"), @@ -68,7 +67,7 @@ object CheckoutScribe : Scribe() { CheckoutScribe.hire(channel = Channel(capacity = 256)) ``` -These values are inserted into the scroll map and then appear in `SealedScroll.data`. +These values are inserted into the scroll map and then appear in the delivered `Entry`. ## Open and Close Hooks with `Margin` @@ -86,7 +85,7 @@ val timingMargin = object : Margin { } object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(ScrollSaver { println(it) }) + override val shelves: List> = listOf(Saver { println(it) }) override val margins = timingMargin } diff --git a/gradle.properties b/gradle.properties index 19aa1d4..68613dd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,6 +6,7 @@ org.gradle.configuration-cache=true kotlin.code.style=official #MPP kotlin.mpp.enableCInteropCommonization=true +kotlin.native.ignoreDisabledTargets=true #Android android.useAndroidX=true android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 161cfec..00c7a90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,22 @@ [versions] -agp = "9.2.1" -kotlin = "2.3.21" -compose-multiplatform = "1.10.3" +agp = "9.3.1" +kotlin = "2.4.10" +compose-multiplatform = "1.11.1" android-minSdk = "24" android-compileSdk = "37" -vanniktechMavenPublish = "0.36.0" +material3 = "1.9.0" +vanniktechMavenPublish = "0.37.0" kotlinxSerialization = "1.11.0" -kotlinxCoroutines = "1.10.2" -androidx-activity-compose = "1.9.3" +kotlinxCoroutines = "1.11.0" +androidx-activity-compose = "1.13.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } -scribe = { module = "com.rafambn:scribe", version = "0.4.0" } +material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } +scribe = { module = "com.rafambn:scribe", version = "0.5.0" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } [plugins] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1a70468..1e922f4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Entry.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Entry.kt new file mode 100644 index 0000000..9c30e3d --- /dev/null +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Entry.kt @@ -0,0 +1,13 @@ +package com.rafambn.scribe + +import kotlinx.serialization.json.JsonElement + +/** Base type for every payload delivered by a [Scribe] runtime. */ +interface Entry + +/** Immutable snapshot emitted when a [Scroll] is sealed. */ +data class ScrollEntry( + val data: Map, +) : Entry, Map by data { + override fun toString(): String = data.toString() +} \ No newline at end of file diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Events.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Events.kt deleted file mode 100644 index 8701253..0000000 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Events.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.rafambn.scribe -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -/** - * Base type for all events emitted by [Scribe]. - */ -@Serializable -sealed interface Entry - -/** - * Final representation of a scroll once it has been sealed. - */ -@Serializable -data class SealedScroll( - val success: Boolean, - val data: Map, -): Entry - -/** - * Lightweight standalone log message emitted through a [Scribe] instance. - */ -@Serializable -data class Note( - val tag: String, - val message: String, - val level: Urgency, - val timestamp: Long, -): Entry - -/** - * Severity level used by [Note]. - */ -@Serializable -enum class Urgency { - VERBOSE, - DEBUG, - INFO, - WARN, - ERROR -} diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 3bf9fd7..367f255 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -77,12 +77,10 @@ abstract class Scribe { val createdProcessor = scope.launch { for (entry in channel) { configuredShelves.forEach { saver -> + if (saver.accepts != null && saver.accepts != entry::class) return@forEach try { - when (saver) { - is EntrySaver -> saver.write(entry) - is ScrollSaver if entry is SealedScroll -> saver.write(entry) - is NoteSaver if entry is Note -> saver.write(entry) - } + @Suppress("UNCHECKED_CAST") + (saver as Saver).write(entry) } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -156,25 +154,6 @@ abstract class Scribe { return false } - /** - * Emits a [Note] immediately, blocking only when the channel buffer is full under [BufferOverflow.SUSPEND][kotlinx.coroutines.channels.BufferOverflow.SUSPEND]. - * - * @param tag logical source/category for the note. - * @param message note text payload. - * @param level severity level for the note. - * @param timestamp epoch milliseconds associated with the note. - */ - fun note(tag: String, message: String, level: Urgency = Urgency.INFO, timestamp: Long = nowEpochMs()) { - requireActiveQueue().trySendBlocking( - Note( - tag = tag, - message = message, - level = level, - timestamp = timestamp, - ), - ) - } - internal fun applyFooter(scroll: Scroll) { margins?.footer(scroll) } @@ -183,7 +162,7 @@ abstract class Scribe { return activeQueue ?: throw IllegalStateException("This Scribe runtime is not active. Call hire(...) first.") } - internal fun enqueue(entry: Entry) { + fun enqueue(entry: Entry) { requireActiveQueue().trySendBlocking(entry) } diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt index 1f62cd7..2f4ec62 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt @@ -1,5 +1,7 @@ package com.rafambn.scribe +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -9,9 +11,12 @@ typealias Scroll = MutableMap val Scroll.id: String get() = this["scroll_id"]?.let { (it as? JsonPrimitive)?.content } ?: error("Invalid scroll id metadata.") -fun Scroll.seal(scribe: Scribe, success: Boolean = true): SealedScroll { +@OptIn(ExperimentalUuidApi::class) +internal fun newScrollId(): String = Uuid.random().toString() + +fun Scroll.seal(scribe: Scribe): ScrollEntry { scribe.applyFooter(this) - val result = SealedScroll(success = success, data = this.toMap()) + val result = ScrollEntry(toMap()) scribe.enqueue(result) return result } diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt index 7ed83ec..c62c02f 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt @@ -1,26 +1,33 @@ package com.rafambn.scribe +import kotlin.reflect.KClass + /** * Contract for persisting [Entry] instances produced by [Scribe]. */ -fun interface Saver { +interface Saver { + /** Exact entry type accepted by this saver, or null to accept every entry. */ + val accepts: KClass? + /** * Handles an emitted event. */ suspend fun write(event: T) } -/** - * Saver specialized for [Note] events. - */ -fun interface NoteSaver : Saver +/** Creates a saver routed only entries whose runtime type is [T]. */ +inline fun Saver( + crossinline write: suspend (T) -> Unit, +): Saver = object : Saver { + override val accepts: KClass = T::class -/** - * Saver specialized for [SealedScroll] events. - */ -fun interface ScrollSaver : Saver + override suspend fun write(event: T) = write.invoke(event) +} /** * Saver that receives all entry types. */ -fun interface EntrySaver : Saver +fun interface EntrySaver : Saver { + override val accepts: KClass? + get() = null +} diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Utils.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Utils.kt deleted file mode 100644 index 6f2e0f6..0000000 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Utils.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.rafambn.scribe - -import kotlin.time.Clock -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -@OptIn(ExperimentalUuidApi::class) -internal fun newScrollId(): String = Uuid.random().toString() - -internal fun nowEpochMs(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/CustomEntry.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/CustomEntry.kt new file mode 100644 index 0000000..6c6bd9f --- /dev/null +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/CustomEntry.kt @@ -0,0 +1,3 @@ +package com.rafambn.scribe + +internal data class CustomEntry(val message: String) : Entry diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt index 8cd9731..90e5926 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt @@ -5,37 +5,35 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals class ScribeConcurrencyAndScrollTest { @Test - fun note_supports_high_throughput_concurrent_writes() { + fun scroll_seal_supports_high_throughput_concurrent_writes() { runSuspend { - val saver = RecordingNoteSaver() - val scribe = scribeWithSavers( - shelves = listOf(saver), - channel = Channel(Channel.UNLIMITED), - ) + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf, channel = Channel(Channel.UNLIMITED)) coroutineScope { repeat(1_000) { index -> launch(Dispatchers.Default) { - scribe.note( - tag = "stress", - message = "msg-$index", - level = Urgency.INFO, - timestamp = index.toLong(), - ) + scribe.newScroll().apply { + this["msg"] = JsonPrimitive("msg-$index") + }.seal(scribe) } } } - saver.awaitEvents(1_000) + shelf.awaitEvents(1_000) scribe.retire() - assertEquals(1_000, saver.events.size) - assertEquals(1_000, saver.events.map { it.message }.toSet().size) + assertEquals(1_000, shelf.events.size) + assertEquals( + 1_000, + shelf.events.map { it["msg"]?.jsonPrimitive?.content }.toSet().size, + ) } } @@ -47,17 +45,16 @@ class ScribeConcurrencyAndScrollTest { val scroll = scribe.newScroll(id = "scroll-id") scroll["state"] = JsonPrimitive("initial") - val first = scroll.seal(scribe, success = false) - val second = scroll.seal(scribe, success = true) + scroll.seal(scribe) + scroll.seal(scribe) shelf.awaitEvents(2) scribe.retire() - assertEquals(false, first.success) - assertEquals(JsonPrimitive("initial"), first.data["state"]) - - assertEquals(true, second.success) - assertEquals(JsonPrimitive("initial"), second.data["state"]) assertEquals(2, shelf.events.size) + shelf.events.forEach { event -> + assertEquals(JsonPrimitive("initial"), event["state"]) + assertEquals("scroll-id", (event["scroll_id"] as? JsonPrimitive)?.content) + } } } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt index 7e7a860..28817e0 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt @@ -23,8 +23,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("mobile-app"), event.data["service"]) - assertEquals(JsonPrimitive("production"), event.data["environment"]) + assertEquals(JsonPrimitive("mobile-app"), event["service"]) + assertEquals(JsonPrimitive("production"), event["environment"]) } } @@ -40,13 +40,13 @@ class ScribeContextMarginTest { shelf.awaitEvents(2) scribe.retire() - val firstEvent = shelf.events.firstOrNull { it.data["scroll_id"]?.jsonPrimitive?.content == "first" } - val secondEvent = shelf.events.firstOrNull { it.data["scroll_id"]?.jsonPrimitive?.content == "second" } + val firstEvent = shelf.events.firstOrNull { it["scroll_id"]?.jsonPrimitive?.content == "first" } + val secondEvent = shelf.events.firstOrNull { it["scroll_id"]?.jsonPrimitive?.content == "second" } assertNotNull(firstEvent) assertNotNull(secondEvent) - assertEquals(JsonPrimitive("us-east"), firstEvent.data["region"]) - assertEquals(JsonPrimitive("us-east"), secondEvent.data["region"]) + assertEquals(JsonPrimitive("us-east"), firstEvent["region"]) + assertEquals(JsonPrimitive("us-east"), secondEvent["region"]) } } @@ -64,8 +64,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("ap-south"), event.data["region"]) - assertEquals(JsonPrimitive("ap-south"), event.data["region"]) + assertEquals(JsonPrimitive("ap-south"), event["region"]) + assertEquals(JsonPrimitive("ap-south"), event["region"]) } } @@ -89,8 +89,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive(1000L), event.data["startedAtEpochMs"]) - assertEquals(JsonPrimitive(2000L), event.data["sealedAtEpochMs"]) + assertEquals(JsonPrimitive(1000L), event["startedAtEpochMs"]) + assertEquals(JsonPrimitive(2000L), event["sealedAtEpochMs"]) } } @@ -105,8 +105,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertFalse(event.data.containsKey("startedAtEpochMs")) - assertFalse(event.data.containsKey("sealedAtEpochMs")) + assertFalse(event.containsKey("startedAtEpochMs")) + assertFalse(event.containsKey("sealedAtEpochMs")) } } @@ -133,8 +133,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive(500L), event.data["elapsedMs"]) - assertFalse(event.data.containsKey("_startTime")) + assertEquals(JsonPrimitive(500L), event["elapsedMs"]) + assertFalse(event.containsKey("_startTime")) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt index 864853a..db7ded2 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt @@ -21,7 +21,7 @@ class ScribeDataSerializationTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonObject(mapOf("retries" to JsonPrimitive(2))), event.data["meta"]) + assertEquals(JsonObject(mapOf("retries" to JsonPrimitive(2))), event["meta"]) } } @@ -41,9 +41,9 @@ class ScribeDataSerializationTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("accepted"), event.data["message"]) - assertEquals(JsonPrimitive(3), event.data["attempt"]) - assertEquals(JsonPrimitive(false), event.data["retry"]) + assertEquals(JsonPrimitive("accepted"), event["message"]) + assertEquals(JsonPrimitive(3), event["attempt"]) + assertEquals(JsonPrimitive(false), event["retry"]) } } @@ -99,7 +99,7 @@ class ScribeDataSerializationTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("value"), event.data["key"]) + assertEquals(JsonPrimitive("value"), event["key"]) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt index b10e2e3..3fb1419 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt @@ -17,6 +17,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds class ScribeDeliveryRetireTest { @Test @@ -41,7 +42,7 @@ class ScribeDeliveryRetireTest { assertEquals(2, secondShelf.events.size) assertEquals( "second-still-active", - secondShelf.events.last().data["scroll_id"]?.jsonPrimitive?.content, + secondShelf.events.last()["scroll_id"]?.jsonPrimitive?.content, ) } } @@ -64,24 +65,59 @@ class ScribeDeliveryRetireTest { } @Test - fun routes_can_select_notes_scrolls_or_both() { + fun scroll_events_reach_scroll_and_entry_savers() { runSuspend { val scrollShelf = RecordingShelf() - val noteSaver = RecordingNoteSaver() val allSaver = RecordingEntrySaver() val scribe = scribeWithSavers( - shelves = listOf(scrollShelf, noteSaver, allSaver), + shelves = listOf(scrollShelf, allSaver), ) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 100L) scribe.newScroll(id = "scroll-1").seal(scribe) scrollShelf.awaitEvents(1) - noteSaver.awaitEvents(1) - allSaver.awaitEvents(2) + allSaver.awaitEvents(1) scribe.retire() - assertTrue(allSaver.events.any { it is Note }) - assertTrue(allSaver.events.any { it is SealedScroll }) + assertEquals(1, scrollShelf.events.size) + assertEquals( + "scroll-1", + scrollShelf.events.single()["scroll_id"]?.jsonPrimitive?.content, + ) + val entry = allSaver.events.single() as ScrollEntry + assertEquals("scroll-1", entry["scroll_id"]?.jsonPrimitive?.content) + } + } + + @Test + fun custom_entries_are_dispatched_only_to_matching_and_wildcard_savers() { + runSuspend { + val customEvents = mutableListOf() + val scrollEvents = mutableListOf() + val allEvents = mutableListOf() + val customWritten = CompletableDeferred() + val allWritten = CompletableDeferred() + val scribe = scribeWithSavers( + shelves = listOf( + Saver { + customEvents += it + customWritten.complete(Unit) + }, + Saver { scrollEvents += it }, + EntrySaver { + allEvents += it + allWritten.complete(Unit) + }, + ), + ) + + scribe.enqueue(CustomEntry("custom")) + customWritten.await() + allWritten.await() + scribe.retire() + + assertEquals(listOf(CustomEntry("custom")), customEvents) + assertTrue(scrollEvents.isEmpty()) + assertEquals(listOf(CustomEntry("custom")), allEvents) } } @@ -112,7 +148,7 @@ class ScribeDeliveryRetireTest { val scribe = scribeWithScrollShelves(shelf) scribe.newScroll(id = "first").seal(scribe) - delay(500) + delay(500.milliseconds) scribe.newScroll(id = "second").seal(scribe) shelf.awaitEvents(2) scribe.retire() @@ -136,7 +172,7 @@ class ScribeDeliveryRetireTest { shelf.awaitEvents(1) scribe.retire() - assertEquals("scoped", shelf.events.single().data["scroll_id"]?.jsonPrimitive?.content) + assertEquals("scoped", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) } } @@ -152,23 +188,23 @@ class ScribeDeliveryRetireTest { firstWriteStarted.await() val retireScope = CoroutineScope(Dispatchers.Default) val retireJob = retireScope.launch { scribe.retire() } - delay(50) + delay(50.milliseconds) assertFalse(retireJob.isCompleted) gate.complete(Unit) - withTimeout(2_000) { retireJob.join() } + withTimeout(2_000.milliseconds) { retireJob.join() } retireScope.cancel() - assertEquals("in-flight", shelf.events.single().data["scroll_id"]?.jsonPrimitive?.content) + assertEquals("in-flight", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) } } @Test - fun note_throws_after_retire() { + fun seal_throws_after_retire() { runSuspend { val scribe = scribeWithScrollShelves(RecordingShelf()) scribe.retire() assertFailsWith { - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 123L) + scribe.newScroll(id = "after").seal(scribe) } } } @@ -186,14 +222,14 @@ class ScribeDeliveryRetireTest { val retireScope = CoroutineScope(Dispatchers.Default) val retireJob = retireScope.launch { scribe.retire() } - delay(50) + delay(50.milliseconds) assertFalse(retireJob.isCompleted) gate.complete(Unit) - withTimeout(2_000) { retireJob.join() } + withTimeout(2_000.milliseconds) { retireJob.join() } retireScope.cancel() - assertEquals("flush-me", shelf.events.single().data["scroll_id"]?.jsonPrimitive?.content) + assertEquals("flush-me", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) } } @@ -208,8 +244,8 @@ class ScribeDeliveryRetireTest { } scribe = scribeWithSavers(shelves = listOf(saver)) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 1L) - withTimeout(2_000) { retired.await() } + scribe.newScroll(id = "retire-1").seal(scribe) + withTimeout(2_000.milliseconds) { retired.await() } } } @@ -228,8 +264,8 @@ class ScribeDeliveryRetireTest { } scribe = scribeWithSavers(shelves = listOf(saver)) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 2L) - withTimeout(2_000) { retired.await() } + scribe.newScroll(id = "retire-2").seal(scribe) + withTimeout(2_000.milliseconds) { retired.await() } } } @@ -248,14 +284,15 @@ class ScribeDeliveryRetireTest { }, ) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 42L) + scribe.newScroll(id = "error-1").seal(scribe) recordingSaver.awaitEvents(1) scribe.retire() assertEquals(1, recordingSaver.events.size) assertEquals(1, events.size) assertEquals(1, errors.size) - assertTrue(events.single() is Note) + val failedEntry = events.single() as ScrollEntry + assertEquals("error-1", failedEntry["scroll_id"]?.jsonPrimitive?.content) assertEquals("boom", errors.single().message) } } @@ -272,8 +309,8 @@ class ScribeDeliveryRetireTest { }, ) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 10L) - scribe.note(tag = "payments", message = "continued", level = Urgency.INFO, timestamp = 11L) + scribe.newScroll(id = "first").seal(scribe) + scribe.newScroll(id = "second").seal(scribe) recordingSaver.awaitEvents(2) scribe.retire() @@ -294,8 +331,8 @@ class ScribeDeliveryRetireTest { }, ) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 12L) - delay(50) + scribe.newScroll(id = "cancellation-probe").seal(scribe) + delay(50.milliseconds) scribe.retire() assertTrue(reportedErrors.isEmpty()) diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt index 3787cf2..2667fb4 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt @@ -4,7 +4,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -16,14 +15,13 @@ class ScribeScrollLifecycleTest { val scribe = scribeWithScrollShelves(shelf) val scroll = scribe.newScroll() scroll["method"] = JsonPrimitive("card") - scroll.seal(scribe, success = true) + scroll.seal(scribe) shelf.awaitEvents(1) scribe.retire() val event = shelf.events.single() - assertEquals(scroll.id, (event.data["scroll_id"] as? JsonPrimitive)?.content) - assertEquals(JsonPrimitive("card"), event.data["method"]) - assertTrue(event.success) + assertEquals(scroll.id, (event["scroll_id"] as? JsonPrimitive)?.content) + assertEquals(JsonPrimitive("card"), event["method"]) } } @@ -36,15 +34,14 @@ class ScribeScrollLifecycleTest { scroll["gateway"] = JsonPrimitive("stripe") - scroll.seal(scribe, success = false) - scroll.seal(scribe, success = true) + scroll.seal(scribe) + scroll.seal(scribe) shelf.awaitEvents(2) scribe.retire() val firstEvent = shelf.events.first() val secondEvent = shelf.events.last() - assertFalse(firstEvent.success) - assertTrue(secondEvent.success) - assertEquals(JsonPrimitive("stripe"), firstEvent.data["gateway"]) + assertEquals(JsonPrimitive("stripe"), firstEvent["gateway"]) + assertEquals(JsonPrimitive("stripe"), secondEvent["gateway"]) } } @@ -62,18 +59,17 @@ class ScribeScrollLifecycleTest { assertFailsWith { paymentService.pay("order2", scroll2, scribe) } - scroll1.seal(scribe, success = true) - scroll2.seal(scribe, success = true) - shelf.awaitEvents(2) + scroll1.seal(scribe) + scroll2.seal(scribe) + shelf.awaitEvents(3) scribe.retire() - val successEvent = shelf.events.firstOrNull { (it.data["scroll_id"] as? JsonPrimitive)?.content == scroll1.id } - val failureEvent = shelf.events.firstOrNull { (it.data["scroll_id"] as? JsonPrimitive)?.content == scroll2.id } + val successEvent = shelf.events.firstOrNull { (it["scroll_id"] as? JsonPrimitive)?.content == scroll1.id } + val failureEvent = shelf.events.firstOrNull { it["error_stage"] != null } assertNotNull(successEvent) assertNotNull(failureEvent) - assertTrue(successEvent.success) - assertFalse(failureEvent.success) + assertEquals(JsonPrimitive("gateway_call"), failureEvent["error_stage"]) } } @@ -103,7 +99,7 @@ class ScribeScrollLifecycleTest { val event = shelf.events.single() assertEquals("session-42", scroll.id) - assertEquals("session-42", (event.data["scroll_id"] as? JsonPrimitive)?.content) + assertEquals("session-42", (event["scroll_id"] as? JsonPrimitive)?.content) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index b5480f5..3bec838 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -12,7 +12,7 @@ internal val UUID_REGEX = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") internal fun scribeWithScrollShelves( - vararg shelves: ScrollSaver, + vararg shelves: Saver, imprint: Map = emptyMap(), channel: Channel = Channel(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST), onSaver: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, @@ -51,7 +51,7 @@ internal fun scribeWithSavers( internal fun runSuspend(block: suspend () -> T): T = runBlocking { block() } -internal fun createScribeInHelperAndEmit(shelf: ScrollSaver): Scribe { +internal fun createScribeInHelperAndEmit(shelf: Saver): Scribe { val scribe = scribeWithScrollShelves(shelf) scribe.newScroll(id = "scoped").seal(scribe) return scribe @@ -67,7 +67,7 @@ internal class PaymentService { scroll["gateway"] = JsonPrimitive("stripe") } catch (t: Throwable) { scroll["error_stage"] = JsonPrimitive("gateway_call") - scroll.seal(scribe, success = false) + scroll.seal(scribe) throw t } } @@ -76,13 +76,13 @@ internal class PaymentService { @Serializable internal data class GatewayMeta(val retries: Int) -internal data class NonSerializableMeta(val retries: Int) - -internal class RecordingShelf : ScrollSaver { - val events = mutableListOf() +internal class RecordingShelf : Saver { + val events = mutableListOf() private val writes = Channel(Channel.UNLIMITED) - override suspend fun write(event: SealedScroll) { + override val accepts get() = ScrollEntry::class + + override suspend fun write(event: ScrollEntry) { events += event writes.trySend(Unit) } @@ -97,11 +97,13 @@ internal class RecordingShelf : ScrollSaver { internal class BlockingShelf( private val gate: CompletableDeferred, private val firstWriteStarted: CompletableDeferred? = null, -) : ScrollSaver { - val events = mutableListOf() +) : Saver { + val events = mutableListOf() private val writes = Channel(Channel.UNLIMITED) - override suspend fun write(event: SealedScroll) { + override val accepts get() = ScrollEntry::class + + override suspend fun write(event: ScrollEntry) { firstWriteStarted?.complete(Unit) gate.await() events += event @@ -115,22 +117,6 @@ internal class BlockingShelf( } } -internal class RecordingNoteSaver : NoteSaver { - val events = mutableListOf() - private val writes = Channel(Channel.UNLIMITED) - - override suspend fun write(event: Note) { - events += event - writes.trySend(Unit) - } - - suspend fun awaitEvents(count: Int) { - repeat(count) { - writes.receive() - } - } -} - internal class RecordingEntrySaver : EntrySaver { val events = mutableListOf() private val writes = Channel(Channel.UNLIMITED) diff --git a/testApp/README.md b/testApp/README.md index 21b0ba8..bb2c4b8 100644 --- a/testApp/README.md +++ b/testApp/README.md @@ -7,11 +7,11 @@ No server or local observability stack is required. ## What This Demo Covers - An application-owned object extending `Scribe` -- `note(...)` +- Quick scrolls: immediately sealed one-shot events - `newScroll(...)` with generated and custom IDs - Direct map-like writes on `Scroll` and explicit delivery runtime selection - Map reads/removals before sealing -- `seal(...)` with success and failure outcomes +- `seal(...)` snapshots and fail-styled scrolls (via data fields) - `Margin.header(...)` and `Margin.footer(...)` - `EntrySaver` - Channel overflow behavior through `DROP_OLDEST` @@ -28,7 +28,7 @@ From the repository root: ./gradlew :testApp:androidApp:installDebug ``` -The UI contains demo actions for notes, scrolls, JSON serialization, queue +The UI contains demo actions for quick scrolls, wide events, JSON serialization, queue delivery, saver failures, and runtime shutdown. Each delivered `Entry` is rendered as JSON and printed to stdout, while the most recent records remain visible in the in-app timeline. @@ -40,7 +40,6 @@ Example console output: "event_kind": "scroll", "demo_name": "checkout_scroll", "scroll_id": "checkout-42", - "success": true, "gateway": "stripe" } ``` @@ -58,10 +57,9 @@ Useful fields include: - `platform` - `app_version` - `saver_type` -- `tag`, `message`, `level` -- `scroll_id`, `success` -- Scroll fields such as `gateway`, `order_id`, `order_snapshot`, and `elapsed_ms` +- `scroll_id` +- Scroll fields such as `tag`, `level`, `success`, `gateway`, `order_id`, `order_snapshot`, and `elapsed_ms` The overflow scenario intentionally slows the console saver while using a small -dropping channel; fewer printed records than attempted notes demonstrates the +dropping channel; fewer printed records than attempted quick scrolls demonstrates the configured overflow behavior. diff --git a/testApp/shared/build.gradle.kts b/testApp/shared/build.gradle.kts index 3d1ee59..a65a98b 100644 --- a/testApp/shared/build.gradle.kts +++ b/testApp/shared/build.gradle.kts @@ -13,14 +13,13 @@ kotlin { compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() } - iosX64() iosArm64() iosSimulatorArm64() sourceSets { commonMain.dependencies { - api(compose.foundation) - implementation(compose.material3) + api("org.jetbrains.compose.foundation:foundation:1.11.1") + implementation(libs.material3) implementation(project(":scribe")) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt index 69f40d9..ed3fc3e 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt @@ -1,8 +1,6 @@ package scribe.demo.data -import com.rafambn.scribe.Entry -import com.rafambn.scribe.Note -import com.rafambn.scribe.SealedScroll +import com.rafambn.scribe.ScrollEntry import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull @@ -11,54 +9,34 @@ import kotlinx.serialization.json.jsonPrimitive typealias ConsoleRecord = Map fun consoleRecordFromEntry( - entry: Entry, + entry: ScrollEntry, demoName: String, platform: String, saverType: String, appVersion: String, recordedAt: Long, -): ConsoleRecord = - when (entry) { - is Note -> linkedMapOf( - "_timestamp" to JsonPrimitive(recordedAt), - "event_kind" to JsonPrimitive("note"), - "demo_name" to JsonPrimitive(demoName), - "platform" to JsonPrimitive(platform), - "app_version" to JsonPrimitive(appVersion), - "saver_type" to JsonPrimitive(saverType), - "tag" to JsonPrimitive(entry.tag), - "message" to JsonPrimitive(entry.message), - "level" to JsonPrimitive(entry.level.name), - "note_timestamp" to JsonPrimitive(entry.timestamp), - ) - - is SealedScroll -> { - val payload = linkedMapOf() - payload["_timestamp"] = JsonPrimitive(recordedAt) - payload["event_kind"] = JsonPrimitive("scroll") - payload["demo_name"] = JsonPrimitive(stringField(entry.data, "demo_name") ?: demoName) - payload["platform"] = JsonPrimitive(platform) - payload["app_version"] = JsonPrimitive(appVersion) - payload["saver_type"] = JsonPrimitive(saverType) - payload["scroll_id"] = JsonPrimitive(stringField(entry.data, "scroll_id") ?: "missing-scroll-id") - payload["success"] = JsonPrimitive(entry.success) - stringField(entry.data, "message")?.let { payload["message"] = JsonPrimitive(it) } - entry.data["order_id"]?.let { payload["order_id"] = it } - ?: entry.data["ordemId"]?.let { payload["order_id"] = it } - entry.data.forEach { (key, value) -> - if (key !in payload) { - payload[key] = value - } - } - payload +): ConsoleRecord { + val payload = linkedMapOf() + payload["_timestamp"] = JsonPrimitive(recordedAt) + payload["event_kind"] = JsonPrimitive("scroll") + payload["demo_name"] = JsonPrimitive(stringField(entry, "demo_name") ?: demoName) + payload["platform"] = JsonPrimitive(platform) + payload["app_version"] = JsonPrimitive(appVersion) + payload["saver_type"] = JsonPrimitive(saverType) + payload["scroll_id"] = JsonPrimitive(stringField(entry, "scroll_id") ?: "missing-scroll-id") + stringField(entry, "message")?.let { payload["message"] = JsonPrimitive(it) } + entry["order_id"]?.let { payload["order_id"] = it } + ?: entry["ordemId"]?.let { payload["order_id"] = it } + entry.forEach { (key, value) -> + if (key !in payload) { + payload[key] = value } } + return payload +} fun recordSummary(record: ConsoleRecord): String = - when (payloadEventKind(record)) { - "note" -> "${record.tag ?: "note"} ${record.level ?: ""}".trim() - else -> "${record.scroll_id ?: "scroll"} success=${record.success}" - } + record.scroll_id ?: "scroll" fun payloadEventKind(record: ConsoleRecord): String = record["event_kind"]?.jsonPrimitive?.contentOrNull ?: "unknown" @@ -66,18 +44,9 @@ fun payloadEventKind(record: ConsoleRecord): String = private fun stringField(data: Map, key: String): String? = data[key]?.jsonPrimitive?.contentOrNull -val ConsoleRecord.tag: String? - get() = this["tag"]?.jsonPrimitive?.contentOrNull - -val ConsoleRecord.level: String? - get() = this["level"]?.jsonPrimitive?.contentOrNull - val ConsoleRecord.scroll_id: String? get() = this["scroll_id"]?.jsonPrimitive?.contentOrNull -val ConsoleRecord.success: String? - get() = this["success"]?.jsonPrimitive?.contentOrNull - fun sampleImprint(platform: String): Map = mapOf( "service" to JsonPrimitive("scribe-showcase"), "environment" to JsonPrimitive("local"), diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt index c524d45..189c008 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt @@ -1,35 +1,35 @@ package scribe.demo.scribe -import com.rafambn.scribe.Entry import com.rafambn.scribe.EntrySaver import com.rafambn.scribe.Margin -import com.rafambn.scribe.Note import com.rafambn.scribe.Scribe import com.rafambn.scribe.Scroll -import com.rafambn.scribe.Urgency +import com.rafambn.scribe.ScrollEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import scribe.demo.currentEpochMillis import scribe.demo.data.sampleImprint import scribe.demo.platformName +import kotlin.time.Duration.Companion.milliseconds -class AppScribe(onRecord: (Entry) -> Unit) : Scribe() { +class AppScribe(onRecord: (ScrollEntry) -> Unit) : Scribe() { var overflowDelay: Boolean = false override val shelves = listOf( EntrySaver { entry -> - if (entry is Note && entry.tag == "saver_failure") { + if (entry is ScrollEntry && entry["tag"]?.jsonPrimitive?.contentOrNull == "saver_failure") { error("Intentional saver failure from showcase demo") } }, EntrySaver { entry -> - if (overflowDelay) delay(220) - onRecord(entry) + if (overflowDelay) delay(220.milliseconds) + if (entry is ScrollEntry) onRecord(entry) }, ) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt index 8f57240..c13c22a 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt @@ -43,8 +43,8 @@ fun HomeContent( saverErrors: List, lastRecord: String, timeline: List, - onRunNoteScenario: () -> Unit, - onRunFlingNoteScenario: () -> Unit, + onRunQuickScrollScenario: () -> Unit, + onRunSecondQuickScrollScenario: () -> Unit, onRunCheckoutScenario: () -> Unit, onRunInspectionScenario: () -> Unit, onRunMarginScenario: () -> Unit, @@ -91,11 +91,11 @@ fun HomeContent( busyLabel = busyLabel, ) ActionGroup( - title = "Notes", - description = "Standalone events emitted through note(...).", + title = "Quick Scrolls", + description = "Immediately sealed one-shot scroll events.", buttons = listOf( - "Run note(...)" to onRunNoteScenario, - "Run second note(...)" to onRunFlingNoteScenario, + "Emit quick scroll" to onRunQuickScrollScenario, + "Emit second quick scroll" to onRunSecondQuickScrollScenario, ), enabled = !isBusy, ) @@ -120,7 +120,7 @@ fun HomeContent( ) ActionGroup( title = "Savers And Delivery", - description = "Use the three saver types, queue overflow behavior, and saver error handling.", + description = "Use the saver types, queue overflow behavior, and saver error handling.", buttons = listOf( "EntrySaver mixed flow" to onRunEntrySaverScenario, "Overflow demo" to onRunOverflowScenario, @@ -162,7 +162,7 @@ private fun HeroCard() { fontWeight = FontWeight.Bold, ) Text( - text = "Guided demos for notes, wide events, margins, queue delivery, and saver behavior. Every delivered record is printed to the console.", + text = "Guided demos for quick scrolls, wide events, margins, queue delivery, and saver behavior. Every delivered record is printed to the console.", color = Color(0xFFE7ECEF), style = MaterialTheme.typography.bodyLarge, ) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt index b525b61..0a7802e 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt @@ -26,8 +26,8 @@ fun HomeScreen() { saverErrors = state.saverErrors, lastRecord = state.lastRecord, timeline = state.timeline, - onRunNoteScenario = viewModel::runNoteScenario, - onRunFlingNoteScenario = viewModel::runFlingNoteScenario, + onRunQuickScrollScenario = viewModel::runQuickScrollScenario, + onRunSecondQuickScrollScenario = viewModel::runSecondQuickScrollScenario, onRunCheckoutScenario = viewModel::runCheckoutScenario, onRunInspectionScenario = viewModel::runInspectionScenario, onRunMarginScenario = viewModel::runMarginScenario, diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt index 4a4526a..53f7c5b 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt @@ -1,10 +1,8 @@ package scribe.demo.ui -import com.rafambn.scribe.Entry -import com.rafambn.scribe.Note +import com.rafambn.scribe.ScrollEntry import com.rafambn.scribe.Scribe import com.rafambn.scribe.Scroll -import com.rafambn.scribe.Urgency import com.rafambn.scribe.id import com.rafambn.scribe.seal import kotlinx.coroutines.CoroutineScope @@ -36,6 +34,7 @@ import scribe.demo.data.recordSummary import scribe.demo.platformName import scribe.demo.scribe.AppScribe import kotlin.collections.set +import kotlin.time.Duration.Companion.milliseconds class HomeViewModel { private val json = Json { @@ -61,28 +60,28 @@ class HomeViewModel { channel = Channel(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST), onSaver = { saver, entry, error -> appendSaverError( - "Saver failure in ${saver::class.simpleName ?: "Saver"} for ${entryKind(entry)}: ${error.message ?: error}", + "Saver failure in ${saver::class.simpleName ?: "Saver"} for ${entryKind()}: ${error.message ?: error}", ) }, ) } - fun runNoteScenario() = launchScenario("Note emission demo") { - appScribe.note( + fun runQuickScrollScenario() = launchScenario("Quick scroll emission demo") { + emitQuickScroll( tag = "checkout", message = "Started checkout for premium customer", - level = Urgency.INFO, + level = "INFO", ) - updateStatus("Ran note(...): a single INFO event was printed through EntrySaver.") + updateStatus("Ran a quick scroll: one immediately sealed event printed through EntrySaver.") } - fun runFlingNoteScenario() = launchScenario("Second note demo") { - appScribe.note( + fun runSecondQuickScrollScenario() = launchScenario("Second quick scroll demo") { + emitQuickScroll( tag = "queue", - message = "Queued retry audit event through note(...)", - level = Urgency.DEBUG, + message = "Queued retry audit event as an immediately sealed scroll", + level = "DEBUG", ) - updateStatus("Ran a second note(...) flow.") + updateStatus("Ran a second immediately sealed scroll flow.") } fun runStringTemplateScenario() = launchScenario("String template scroll demo") { @@ -90,7 +89,7 @@ class HomeViewModel { scroll["demo_name"] = JsonPrimitive("string_template_render") scroll["message"] = JsonPrimitive("error on order_id=\$order_id") scroll["order_id"] = JsonPrimitive(555) - sealScroll(scroll, appScribe, success = true) + sealScroll(scroll, appScribe) appendTimeline( title = "Template message preview", detail = "Sent scroll with {message: \"error on order_id=\$order_id\", order_id: 555}.", @@ -115,7 +114,7 @@ class HomeViewModel { featureFlag = "wide-events", ), ) - sealScroll(scroll, appScribe, success = true) + sealScroll(scroll, appScribe) updateStatus("Ran newScroll + map writes + seal for a wide checkout event.") } @@ -136,7 +135,7 @@ class HomeViewModel { payload = "", success = true, ) - sealScroll(scroll, appScribe, success = true) + sealScroll(scroll, appScribe) updateStatus("Ran custom-id scroll demo with map reads/removals and local active-scroll tracking.") } @@ -147,9 +146,10 @@ class HomeViewModel { scroll["warehouse"] = JsonPrimitive("gru-1") scroll["cache_hit"] = JsonPrimitive(false) scroll["failure_reason"] = JsonPrimitive("downstream retry scheduled") - sealScroll(scroll, appScribe, success = false) - delay(250) - updateStatus("Ran Margin header/footer hooks with seal(success = false).") + scroll["success"] = JsonPrimitive(false) + sealScroll(scroll, appScribe) + delay(250.milliseconds) + updateStatus("Ran Margin header/footer hooks on a failed scroll, with success recorded as a data field.") } fun runJsonSerializationScenario() = launchScenario("JSON serialization scroll demo") { @@ -190,22 +190,22 @@ class HomeViewModel { "order_snapshot.order_id,order_snapshot.buyer.tier,order_snapshot.line_items[0].sku,order_snapshot.metadata.channel,order_id,buyer_tier,primary_sku,channel,order_item_count,order_tag_count", ) - sealScroll(scroll, appScribe, success = true) + sealScroll(scroll, appScribe) updateStatus("Ran JSON serialization demo with a nested object payload for console inspection.") } fun runEntrySaverScenario() = launchScenario("Unified EntrySaver demo") { - appScribe.note( + emitQuickScroll( tag = "auth", message = "Session accepted for staff dashboard", - level = Urgency.INFO, + level = "INFO", ) val scroll = openScroll(appScribe, id = "session-audit") scroll["demo_name"] = JsonPrimitive("entry_saver_demo") scroll["role"] = JsonPrimitive("support") scroll["elevated_access"] = JsonPrimitive(true) - sealScroll(scroll, appScribe, success = true) - updateStatus("Ran a mixed note + scroll demo through one EntrySaver path.") + sealScroll(scroll, appScribe) + updateStatus("Ran two scrolls through one EntrySaver path.") } fun runOverflowScenario() = launchScenario("Overflow demo") { @@ -214,19 +214,19 @@ class HomeViewModel { appScribe.overflowDelay = true repeat(attempted) { index -> - appScribe.note( + emitQuickScroll( tag = "buffer", message = "burst event #$index", - level = if (index % 3 == 0) Urgency.WARN else Urgency.INFO, + level = if (index % 3 == 0) "WARN" else "INFO", ) } - delay(1800) + delay(1800.milliseconds) appScribe.overflowDelay = false val delivered = printedEvents - baseline appendTimeline( title = "Overflow result", - detail = "Attempted $attempted notes with channel capacity 2 and DROP_OLDEST; delivered $delivered.", + detail = "Attempted $attempted quick scrolls with channel capacity 2 and DROP_OLDEST; delivered $delivered.", payload = "", success = delivered < attempted, ) @@ -234,16 +234,16 @@ class HomeViewModel { } fun runSaverFailureScenario() = launchScenario("Saver error demo") { - appScribe.note( + emitQuickScroll( tag = "saver_failure", message = "Intentional saver failure probe", - level = Urgency.WARN, + level = "WARN", ) updateStatus("Saver failure demo ran; onSaver callback captures the injected failure.") } fun runRetireScenario() = launchScenario("retire() demo") { - appScribe.note("shutdown", "retire() with light queue", Urgency.INFO) + emitQuickScroll("shutdown", "retire() with light queue", "INFO") val started = currentEpochMillis() appScribe.retire() val elapsed = currentEpochMillis() - started @@ -262,7 +262,7 @@ class HomeViewModel { fun runPlanRetireScenario() = launchScenario("retire() with backlog demo") { repeat(6) { index -> - appScribe.note("shutdown", "drain probe #$index", Urgency.INFO) + emitQuickScroll("shutdown", "drain probe #$index", "INFO") } val started = currentEpochMillis() appScribe.retire() @@ -281,10 +281,10 @@ class HomeViewModel { } fun wireIgnitionScenario() = launchScenario("onIgnition wiring") { - appScribe.note( + emitQuickScroll( tag = "ignition", message = "onIgnition callback is configured; the demo avoids firing an uncaught exception.", - level = Urgency.INFO, + level = "INFO", ) _state.update { it.copy( @@ -311,7 +311,7 @@ class HomeViewModel { scope = scope, onSaver = { saver, entry, error -> appendSaverError( - "Saver failure in ${saver::class.simpleName ?: "Saver"} for ${entryKind(entry)}: ${error.message ?: error}", + "Saver failure in ${saver::class.simpleName ?: "Saver"} for ${entryKind()}: ${error.message ?: error}", ) }, ) @@ -351,7 +351,7 @@ class HomeViewModel { } } - private fun handleRecord(entry: Entry) { + private fun handleRecord(entry: ScrollEntry) { val record = consoleRecordFromEntry( entry = entry, demoName = "shared_session", @@ -424,15 +424,20 @@ class HomeViewModel { return scroll } - private suspend fun sealScroll(scroll: Scroll, scribe: Scribe, success: Boolean) { - scroll.seal(scribe, success = success) + private fun sealScroll(scroll: Scroll, scribe: Scribe) { + scroll.seal(scribe) activeScrolls.remove(scroll.id) refreshActiveScrolls() } - private fun entryKind(entry: Entry): String = - when (entry) { - is Note -> "note" - else -> "scroll" - } + private fun entryKind(): String = "scroll" + + private fun emitQuickScroll(tag: String, message: String, level: String) { + val scroll = appScribe.newScroll() + scroll["demo_name"] = JsonPrimitive("quick_scroll") + scroll["tag"] = JsonPrimitive(tag) + scroll["message"] = JsonPrimitive(message) + scroll["level"] = JsonPrimitive(level) + scroll.seal(appScribe) + } } From 12e6a61dc28d6e7e324b1f5bb1bff40b9b949c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Fri, 7 Aug 2026 11:39:06 -0300 Subject: [PATCH 02/11] Refactor: rename `Saver` to `Archivist` and `ScrollEntry` to `Entry`, update API, tests, and documentation --- README.md | 15 ++- docs/api-concepts.md | 38 ++++--- docs/console-showcase.md | 8 +- docs/getting-started.md | 24 ++--- docs/lifecycle-and-delivery.md | 18 ++-- .../kotlin/com/rafambn/scribe/Archivist.kt | 11 ++ .../kotlin/com/rafambn/scribe/Entry.kt | 13 --- .../kotlin/com/rafambn/scribe/Scribe.kt | 24 ++--- .../kotlin/com/rafambn/scribe/Scroll.kt | 9 +- .../kotlin/com/rafambn/scribe/Shelf.kt | 33 ------ .../kotlin/com/rafambn/scribe/CustomEntry.kt | 3 - .../scribe/ScribeDeliveryRetireTest.kt | 102 ++++++------------ .../com/rafambn/scribe/ScribeTestFixtures.kt | 54 +++------- testApp/README.md | 8 +- .../kotlin/scribe/demo/data/ConsoleRecord.kt | 8 +- .../kotlin/scribe/demo/scribe/AppScribe.kt | 16 +-- .../kotlin/scribe/demo/ui/HomeContent.kt | 24 ++--- .../kotlin/scribe/demo/ui/HomeScreen.kt | 6 +- .../kotlin/scribe/demo/ui/HomeState.kt | 2 +- .../kotlin/scribe/demo/ui/HomeViewModel.kt | 42 ++++---- 20 files changed, 180 insertions(+), 278 deletions(-) create mode 100644 scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt delete mode 100644 scribe/src/commonMain/kotlin/com/rafambn/scribe/Entry.kt delete mode 100644 scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt delete mode 100644 scribe/src/commonTest/kotlin/com/rafambn/scribe/CustomEntry.kt diff --git a/README.md b/README.md index f957ad1..239d976 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - Story-driven logging primitives instead of flat logger calls - Contextual logging with `newScroll(...)` and immediate-seal one-shot scrolls -- Delivery hooks through typed `Saver` instances and `EntrySaver` +- Delivery hooks through `Archivist` instances receiving `Entry` snapshots - Scroll lifecycle enrichment through `Margin` - Independent `Scribe` objects for applications and imported libraries @@ -56,8 +56,8 @@ Create a `Scribe` object, hire its runtime, and emit a scroll: ```kotlin object AppScribe : Scribe() { - override val shelves: List> = listOf( - Saver { scroll -> + override val shelves: List = listOf( + Archivist { scroll -> println(scroll) } ) @@ -75,8 +75,8 @@ Use a scroll when you need shared context for a longer flow: ```kotlin object BillingScribe : Scribe() { - override val shelves: List> = listOf( - Saver { scroll -> println(scroll) } + override val shelves: List = listOf( + Archivist { scroll -> println(scroll) } ) override val imprint = mapOf( "service" to JsonPrimitive("billing"), @@ -94,9 +94,8 @@ scroll.seal(BillingScribe) Each `Scribe` object has independent configuration and delivery lifecycle. A `Scroll` is a mutable JSON-element map initialized by `newScroll(...)`; pass the runtime that should enrich and deliver it to `scroll.seal(scribe)`. Each `seal(...)` call emits a separate snapshot of the scroll data. -Choose the saver that matches your output flow: +Choose the archivist that matches your output flow: ```kotlin -val scrollSaver = Saver { scroll -> println(scroll) } -val entrySaver = EntrySaver { record -> println(record) } +val scrollArchivist = Archivist { scroll -> println(scroll) } ``` diff --git a/docs/api-concepts.md b/docs/api-concepts.md index f7a0169..3563fd3 100644 --- a/docs/api-concepts.md +++ b/docs/api-concepts.md @@ -2,21 +2,20 @@ ## Core Types -Scribe models logging with typed entries: +Scribe models logging with structured scroll events: - `Scroll`: a mutable JSON-map you build up and then pass to `seal(...)` -- `Entry`: the open base interface for every payload sent through a runtime -- `ScrollEntry`: the immutable map snapshot produced by sealing a `Scroll` +- `Entry`: typealias for `Map`, the immutable snapshot produced by sealing a `Scroll` and delivered through a runtime's savers ## Terminology - `newScroll(...)`: starts a contextual logging session - `seal(scribe)`: applies the supplied runtime's footer, snapshots the - current scroll data, and emits a `ScrollEntry` + current scroll data, and emits an `Entry` - `extend(scroll)`: copies missing keys from another scroll into this one - `append(key, scroll)`: nests a scroll as a JSON object under the given key - `Margin`: hook for writing fields at open/close boundaries -- `hire(channel = ..., scope = ..., onSaver = ...)`: starts delivery over your channel configuration +- `hire(channel = ..., scope = ..., onArchivist = ...)`: starts delivery over your channel configuration ## `Scribe` @@ -33,7 +32,7 @@ Define runtime configuration with overridden properties: ```kotlin object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(entrySaver) + override val shelves: List = listOf(Archivist { entry -> println(entry) }) override val imprint = mapOf("service" to JsonPrimitive("checkout")) override val margins = timingMargin } @@ -81,7 +80,7 @@ println(scroll.id) // "checkout-42" ``` Calling `seal(...)` more than once is allowed. Each call emits a separate -`ScrollEntry` through the `Scribe` passed to that call, with a snapshot of the data +`Entry` through the `Scribe` passed to that call, with a snapshot of the data at that point. ## `Scroll` Operations @@ -137,8 +136,8 @@ CheckoutScribe.hire( capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST, ), - onSaver = { saver, entry, error -> - println("Saver $saver failed for $entry: $error") + onArchivist = { archivist, entry, error -> + println("Archivist $archivist failed for $entry: $error") }, ) ``` @@ -157,14 +156,13 @@ CheckoutScribe.hire( ## Event Shapes The standard delivered event is a sealed `Scroll` snapshot. Fields written to -the scroll via normal map operations appear directly in the delivered `ScrollEntry`: +the scroll via normal map operations appear directly in the delivered `Entry`, +which is a `Map`: ```kotlin -ScrollEntry( - mapOf( - "scroll_id" to JsonPrimitive("checkout-42"), - "gateway" to JsonPrimitive("stripe"), - ), +mapOf( + "scroll_id" to JsonPrimitive("checkout-42"), + "gateway" to JsonPrimitive("stripe"), ) ``` @@ -172,7 +170,7 @@ ScrollEntry( ```kotlin object ApplicationScribe : Scribe() { - override val shelves: List> = listOf(entrySaver) + override val shelves: List = listOf(Archivist { entry -> println(entry) }) override val onIgnition: ((Throwable) -> Unit)? = { throwable -> println("Uncaught exception: ${throwable.message}") } @@ -180,13 +178,13 @@ object ApplicationScribe : Scribe() { ApplicationScribe.hire( channel = Channel(capacity = 256), - onSaver = { saver, entry, error -> - println("Saver $saver failed for $entry: ${error.message}") + onArchivist = { archivist, entry, error -> + println("Archivist $archivist failed for $entry: ${error.message}") }, ) ``` `onIgnition` is read when that runtime is first hired, but handles uncaught exceptions at the platform level. Multiple runtimes should not independently -claim this application-global hook. Saver failures are reported by the -`onSaver` callback passed to `hire(...)`. +claim this application-global hook. Archivist failures are reported by the +`onArchivist` callback passed to `hire(...)`. diff --git a/docs/console-showcase.md b/docs/console-showcase.md index 874e592..056c2e9 100644 --- a/docs/console-showcase.md +++ b/docs/console-showcase.md @@ -13,9 +13,9 @@ application console. There is no external service to configure. - Map read/remove operations - `seal(...)` snapshots and fail-styled scrolls (via data fields) - `Margin` -- `EntrySaver` +- `Archivist` fanned out across multiple outputs - Channel overflow behavior through `DROP_OLDEST` -- Saver error callbacks +- Archivist error callbacks - `retire()` and runtime re-hire - Safe `onIgnition` wiring @@ -35,9 +35,9 @@ records are available through their platform run consoles. 3. Run `Margins + seal(failure)` and verify timing fields plus the `failure_reason`/`success=false` data markers. 4. Run `JSON object serialization` to inspect a nested payload. 5. Run `String template message` to inspect the `message` and `order_id` fields. -6. Run `EntrySaver mixed flow` to print two scroll shapes through one saver. +6. Run `Archivist mixed flow` to print two scroll shapes through one archivist. 7. Run `Overflow demo` and observe that a burst can be trimmed under pressure. -8. Run `Saver failure demo` and observe the printed saver error while delivery continues. +8. Run `Archivist failure demo` and observe the printed archivist error while delivery continues. 9. Compare `retire() (light queue)` with `retire() with backlog`. The in-app timeline mirrors delivered console records for convenient inspection. diff --git a/docs/getting-started.md b/docs/getting-started.md index e556b8e..9390e01 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -21,7 +21,7 @@ object's runtime with a `Channel`. ```kotlin object AppScribe : Scribe() { - override val shelves: List> = listOf(Saver { scroll -> + override val shelves: List = listOf(Archivist { scroll -> println(scroll) }) } @@ -47,7 +47,7 @@ scroll["level"] = JsonPrimitive("INFO") scroll.seal(AppScribe) ``` -With the saver above, the log output looks like this: +With the archivist above, the log output looks like this: ```text {scroll_id=..., tag=payments, message=starting checkout, level=INFO} @@ -93,11 +93,11 @@ application may supply a configured object to a component. ```kotlin object PaymentsScribe : Scribe() { - override val shelves: List> = listOf(EntrySaver { sendPaymentsRecord(it) }) + override val shelves: List = listOf(Archivist { sendPaymentsRecord(it) }) } object AnalyticsScribe : Scribe() { - override val shelves: List> = listOf(EntrySaver { sendAnalyticsRecord(it) }) + override val shelves: List = listOf(Archivist { sendAnalyticsRecord(it) }) } PaymentsScribe.hire(channel = Channel(256)) @@ -122,21 +122,17 @@ The emitted event shape is the scroll map itself: } ``` -## Choose the Right Saver +## Choose the Right Archivist ```kotlin -val scrollSaver = Saver { scroll -> println(scroll) } -val entrySaver = EntrySaver { entry -> println(entry) } - -data class AuditEntry(val message: String) : Entry -val auditSaver = Saver { audit -> println(audit.message) } +val scrollArchivist = Archivist { scroll -> println(scroll) } ``` -- `Saver` handles scroll snapshots -- `Saver` handles entries whose runtime type is exactly `T` -- `EntrySaver` is the wildcard and handles every entry from the runtime +- Every archivist receives `Entry` snapshots +- `Archivist` is a functional interface: `Archivist { entry -> ... }` is all you need +- Add multiple savers to a `Scribe` object to fan out to several outputs ## What to Read Next - [API Concepts](api-concepts.md) for the core types and terminology -- [Lifecycle and Delivery](lifecycle-and-delivery.md) for channel behavior, margins, shutdown, and saver error callbacks +- [Lifecycle and Delivery](lifecycle-and-delivery.md) for channel behavior, margins, shutdown, and archivist error callbacks diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index 0519a20..fd93155 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -2,7 +2,7 @@ ## Delivery Pipeline -A `Scribe` object delivers entries through the `Channel` provided to +A `Scribe` object delivers `Entry` snapshots through the `Channel` provided to `hire(...)`. The channel is disposable and transfers ownership to that object, which closes it on processor completion or `retire()`. Different `Scribe` objects may be hired concurrently with independent channels. @@ -20,7 +20,7 @@ CheckoutScribe.hire( ```kotlin object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(EntrySaver { entry -> + override val shelves: List = listOf(Archivist { entry -> println(entry) }) } @@ -30,8 +30,8 @@ CheckoutScribe.hire( capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST, ), - onSaver = { saver, entry, error -> - println("Saver $saver failed for $entry: ${error.message}") + onArchivist = { archivist, entry, error -> + println("Archivist $archivist failed for $entry: ${error.message}") }, ) ``` @@ -44,7 +44,7 @@ Current emission calls are non-suspending and always produce scroll events: current `Scroll` data, and sends the resulting `Entry` Calls attempt an immediate channel send and block the calling thread if a -channel configured with `BufferOverflow.SUSPEND` is full. `Saver.write(...)` +channel configured with `BufferOverflow.SUSPEND` is full. `Archivist.write(...)` and `retire()` are the suspending parts of the API. There are no separate best-effort emission APIs in this runtime shape. @@ -57,7 +57,7 @@ emits a separate `Entry` through the `Scribe` passed to that call. ```kotlin object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(Saver { println(it) }) + override val shelves: List = listOf(Archivist { println(it) }) override val imprint = mapOf( "app" to JsonPrimitive("checkout"), "region" to JsonPrimitive("us-east-1"), @@ -85,7 +85,7 @@ val timingMargin = object : Margin { } object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(Saver { println(it) }) + override val shelves: List = listOf(Archivist { println(it) }) override val margins = timingMargin } @@ -111,7 +111,7 @@ platform uncaught exception hook when that object is first hired: ```kotlin object ApplicationScribe : Scribe() { - override val shelves: List> = listOf(EntrySaver { println(it) }) + override val shelves: List = listOf(Archivist { println(it) }) override val onIgnition: ((Throwable) -> Unit)? = { throwable -> println("Uncaught exception: ${throwable.message}") } @@ -119,5 +119,5 @@ object ApplicationScribe : Scribe() { ``` This hook is platform-global even though the property is declared by one -runtime object. Saver-level failures are handled separately by `onSaver` passed +runtime object. Archivist-level failures are handled separately by `onArchivist` passed to `hire(...)`. diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt new file mode 100644 index 0000000..4fb0837 --- /dev/null +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt @@ -0,0 +1,11 @@ +package com.rafambn.scribe + +/** + * Contract for persisting [Entry] structured logs produced by [Scribe]. + */ +fun interface Archivist { + /** + * Handles an emitted structured log. + */ + suspend fun write(event: Entry) +} \ No newline at end of file diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Entry.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Entry.kt deleted file mode 100644 index 9c30e3d..0000000 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Entry.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.rafambn.scribe - -import kotlinx.serialization.json.JsonElement - -/** Base type for every payload delivered by a [Scribe] runtime. */ -interface Entry - -/** Immutable snapshot emitted when a [Scroll] is sealed. */ -data class ScrollEntry( - val data: Map, -) : Entry, Map by data { - override fun toString(): String = data.toString() -} \ No newline at end of file diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 367f255..93619b1 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -14,21 +14,21 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive /** - * Independent event writer that creates [Scroll]s and dispatches [Entry] objects to configured savers. + * Independent structured log writer that creates [Scroll]s and dispatches [Entry] snapshots to configured archivists. * * Create an object that extends this type and override its configuration: * * ``` * object AppScribe : Scribe() { - * override val shelves = listOf(EntrySaver { entry -> println(entry) }) + * override val shelves = listOf(Archivist { entry -> println(entry) }) * } * ``` */ abstract class Scribe { /** - * Savers receiving entries emitted by this instance. + * Archivists receiving structured logs emitted by this instance. */ - protected abstract val shelves: List> + protected abstract val shelves: List /** * Fields copied into every [Scroll] created by this instance. @@ -62,7 +62,7 @@ abstract class Scribe { fun hire( scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), channel: Channel, - onSaver: ((saver: Saver<*>, entry: Entry, error: Throwable) -> Unit)? = null, + onArchiveFailure: ((archivist: Archivist, entry: Entry, error: Throwable) -> Unit)? = null, ) { val configuredShelves = shelves require(configuredShelves.isNotEmpty()) { "At least one shelf is required." } @@ -76,16 +76,14 @@ abstract class Scribe { activeQueue = channel val createdProcessor = scope.launch { for (entry in channel) { - configuredShelves.forEach { saver -> - if (saver.accepts != null && saver.accepts != entry::class) return@forEach + configuredShelves.forEach { archivist -> try { - @Suppress("UNCHECKED_CAST") - (saver as Saver).write(entry) + archivist.write(entry) } catch (e: CancellationException) { throw e } catch (e: Throwable) { try { - onSaver?.invoke(saver, entry, e) + onArchiveFailure?.invoke(archivist, entry, e) } catch (_: Throwable) { // Ignore callback failures to keep delivery alive. } @@ -119,12 +117,12 @@ abstract class Scribe { } /** - * Stops accepting entries, closes the delivery channel, and waits for queued events to finish delivery. + * Stops accepting structured logs, closes the delivery channel, and waits for queued logs to finish delivery. * * The channel passed to [hire] is closed and must not be reused. * After this call completes, you may call [hire] again with a fresh channel. * - * If called from within the processor coroutine (e.g., from a saver), + * If called from within the processor coroutine (e.g., from a archivist), * this function returns immediately without waiting to avoid deadlocks. */ suspend fun retire() { @@ -173,4 +171,4 @@ abstract class Scribe { runCatching { send(element) } } } -} +} \ No newline at end of file diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt index 2f4ec62..ab76c67 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt @@ -8,15 +8,18 @@ import kotlinx.serialization.json.JsonPrimitive typealias Scroll = MutableMap +/** Immutable structured log emitted when a [Scroll] is sealed. */ +typealias Entry = Map + val Scroll.id: String get() = this["scroll_id"]?.let { (it as? JsonPrimitive)?.content } ?: error("Invalid scroll id metadata.") @OptIn(ExperimentalUuidApi::class) internal fun newScrollId(): String = Uuid.random().toString() -fun Scroll.seal(scribe: Scribe): ScrollEntry { +fun Scroll.seal(scribe: Scribe): Entry { scribe.applyFooter(this) - val result = ScrollEntry(toMap()) + val result: Entry = toMap() scribe.enqueue(result) return result } @@ -39,4 +42,4 @@ fun Scroll.extend(scroll: Scroll): Scroll { fun Scroll.append(key: String, scroll: Scroll): Scroll { this[key] = JsonObject(scroll.toMap()) return this -} +} \ No newline at end of file diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt deleted file mode 100644 index c62c02f..0000000 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.rafambn.scribe - -import kotlin.reflect.KClass - -/** - * Contract for persisting [Entry] instances produced by [Scribe]. - */ -interface Saver { - /** Exact entry type accepted by this saver, or null to accept every entry. */ - val accepts: KClass? - - /** - * Handles an emitted event. - */ - suspend fun write(event: T) -} - -/** Creates a saver routed only entries whose runtime type is [T]. */ -inline fun Saver( - crossinline write: suspend (T) -> Unit, -): Saver = object : Saver { - override val accepts: KClass = T::class - - override suspend fun write(event: T) = write.invoke(event) -} - -/** - * Saver that receives all entry types. - */ -fun interface EntrySaver : Saver { - override val accepts: KClass? - get() = null -} diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/CustomEntry.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/CustomEntry.kt deleted file mode 100644 index 6c6bd9f..0000000 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/CustomEntry.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.rafambn.scribe - -internal data class CustomEntry(val message: String) : Entry diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt index 3fb1419..1b8bbea 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt @@ -65,17 +65,17 @@ class ScribeDeliveryRetireTest { } @Test - fun scroll_events_reach_scroll_and_entry_savers() { + fun scroll_events_reach_all_configured_archivists() { runSuspend { val scrollShelf = RecordingShelf() - val allSaver = RecordingEntrySaver() - val scribe = scribeWithSavers( - shelves = listOf(scrollShelf, allSaver), + val secondSearcher = RecordingShelf() + val scribe = scribeWithArchivists( + shelves = listOf(scrollShelf, secondSearcher), ) scribe.newScroll(id = "scroll-1").seal(scribe) scrollShelf.awaitEvents(1) - allSaver.awaitEvents(1) + secondSearcher.awaitEvents(1) scribe.retire() assertEquals(1, scrollShelf.events.size) @@ -83,41 +83,7 @@ class ScribeDeliveryRetireTest { "scroll-1", scrollShelf.events.single()["scroll_id"]?.jsonPrimitive?.content, ) - val entry = allSaver.events.single() as ScrollEntry - assertEquals("scroll-1", entry["scroll_id"]?.jsonPrimitive?.content) - } - } - - @Test - fun custom_entries_are_dispatched_only_to_matching_and_wildcard_savers() { - runSuspend { - val customEvents = mutableListOf() - val scrollEvents = mutableListOf() - val allEvents = mutableListOf() - val customWritten = CompletableDeferred() - val allWritten = CompletableDeferred() - val scribe = scribeWithSavers( - shelves = listOf( - Saver { - customEvents += it - customWritten.complete(Unit) - }, - Saver { scrollEvents += it }, - EntrySaver { - allEvents += it - allWritten.complete(Unit) - }, - ), - ) - - scribe.enqueue(CustomEntry("custom")) - customWritten.await() - allWritten.await() - scribe.retire() - - assertEquals(listOf(CustomEntry("custom")), customEvents) - assertTrue(scrollEvents.isEmpty()) - assertEquals(listOf(CustomEntry("custom")), allEvents) + assertEquals("scroll-1", secondSearcher.events.single()["scroll_id"]?.jsonPrimitive?.content) } } @@ -234,15 +200,15 @@ class ScribeDeliveryRetireTest { } @Test - fun retire_called_from_saver_does_not_deadlock() { + fun retire_called_from_archivist_does_not_deadlock() { runSuspend { val retired = CompletableDeferred() lateinit var scribe: Scribe - val saver = EntrySaver { + val archivist = Archivist { scribe.retire() retired.complete(Unit) } - scribe = scribeWithSavers(shelves = listOf(saver)) + scribe = scribeWithArchivists(shelves = listOf(archivist)) scribe.newScroll(id = "retire-1").seal(scribe) withTimeout(2_000.milliseconds) { retired.await() } @@ -250,11 +216,11 @@ class ScribeDeliveryRetireTest { } @Test - fun retire_called_from_saver_child_coroutine_does_not_deadlock() { + fun retire_called_from_archivist_child_coroutine_does_not_deadlock() { runSuspend { val retired = CompletableDeferred() lateinit var scribe: Scribe - val saver = EntrySaver { + val archivist = Archivist { coroutineScope { launch { scribe.retire() @@ -262,7 +228,7 @@ class ScribeDeliveryRetireTest { } } } - scribe = scribeWithSavers(shelves = listOf(saver)) + scribe = scribeWithArchivists(shelves = listOf(archivist)) scribe.newScroll(id = "retire-2").seal(scribe) withTimeout(2_000.milliseconds) { retired.await() } @@ -270,63 +236,63 @@ class ScribeDeliveryRetireTest { } @Test - fun onSaverError_is_called_and_other_savers_continue() { + fun onArchivistError_is_called_and_other_archivists_continue() { runSuspend { val events = mutableListOf() val errors = mutableListOf() - val failingSaver = EntrySaver { throw IllegalStateException("boom") } - val recordingSaver = RecordingEntrySaver() - val scribe = scribeWithSavers( - shelves = listOf(failingSaver, recordingSaver), - onSaver = { _, entry, error -> + val failingArchivist = Archivist { throw IllegalStateException("boom") } + val recordingArchivist = RecordingShelf() + val scribe = scribeWithArchivists( + shelves = listOf(failingArchivist, recordingArchivist), + onArchivist = { _, entry, error -> events += entry errors += error }, ) scribe.newScroll(id = "error-1").seal(scribe) - recordingSaver.awaitEvents(1) + recordingArchivist.awaitEvents(1) scribe.retire() - assertEquals(1, recordingSaver.events.size) + assertEquals(1, recordingArchivist.events.size) assertEquals(1, events.size) assertEquals(1, errors.size) - val failedEntry = events.single() as ScrollEntry + val failedEntry = events.single() assertEquals("error-1", failedEntry["scroll_id"]?.jsonPrimitive?.content) assertEquals("boom", errors.single().message) } } @Test - fun onSaverError_callback_failure_does_not_stop_delivery() { + fun onArchivistError_callback_failure_does_not_stop_delivery() { runSuspend { - val failingSaver = EntrySaver { throw IllegalStateException("boom") } - val recordingSaver = RecordingEntrySaver() - val scribe = scribeWithSavers( - shelves = listOf(failingSaver, recordingSaver), - onSaver = { _, _, _ -> + val failingArchivist = Archivist { throw IllegalStateException("boom") } + val recordingArchivist = RecordingShelf() + val scribe = scribeWithArchivists( + shelves = listOf(failingArchivist, recordingArchivist), + onArchivist = { _, _, _ -> throw IllegalStateException("callback-failed") }, ) scribe.newScroll(id = "first").seal(scribe) scribe.newScroll(id = "second").seal(scribe) - recordingSaver.awaitEvents(2) + recordingArchivist.awaitEvents(2) scribe.retire() - assertEquals(2, recordingSaver.events.size) + assertEquals(2, recordingArchivist.events.size) } } @Test - fun saver_cancellation_is_not_reported_to_onSaver() { + fun archivist_cancellation_is_not_reported_to_onArchivist() { runSuspend { val reportedErrors = mutableListOf() - val cancelingSaver = EntrySaver { throw CancellationException("cancel-delivery") } - val scribe = scribeWithSavers( - shelves = listOf(cancelingSaver), + val cancelingArchivist = Archivist { throw CancellationException("cancel-delivery") } + val scribe = scribeWithArchivists( + shelves = listOf(cancelingArchivist), channel = Channel(capacity = 16), - onSaver = { _, _, error -> + onArchivist = { _, _, error -> reportedErrors += error }, ) diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index 3bec838..317d583 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -12,46 +12,46 @@ internal val UUID_REGEX = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") internal fun scribeWithScrollShelves( - vararg shelves: Saver, + vararg shelves: Archivist, imprint: Map = emptyMap(), channel: Channel = Channel(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST), - onSaver: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, + onArchivist: (archivist: Archivist, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, margins: Margin? = null, ): Scribe { val configuredShelves = shelves.toList() val configuredImprint = imprint val configuredMargins = margins return object : Scribe() { - override val shelves: List> = configuredShelves + override val shelves: List = configuredShelves override val imprint: Map = configuredImprint override val margins: Margin? = configuredMargins }.also { - it.hire(channel = channel, onSaver = onSaver) + it.hire(channel = channel, onArchiveFailure = onArchivist) } } -internal fun scribeWithSavers( - shelves: List>, +internal fun scribeWithArchivists( + shelves: List, imprint: Map = emptyMap(), margins: Margin? = null, channel: Channel = Channel(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST), - onSaver: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, + onArchivist: (archivist: Archivist, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, ): Scribe { val configuredShelves = shelves val configuredImprint = imprint val configuredMargins = margins return object : Scribe() { - override val shelves: List> = configuredShelves + override val shelves: List = configuredShelves override val imprint: Map = configuredImprint override val margins: Margin? = configuredMargins }.also { - it.hire(channel = channel, onSaver = onSaver) + it.hire(channel = channel, onArchiveFailure = onArchivist) } } internal fun runSuspend(block: suspend () -> T): T = runBlocking { block() } -internal fun createScribeInHelperAndEmit(shelf: Saver): Scribe { +internal fun createScribeInHelperAndEmit(shelf: Archivist): Scribe { val scribe = scribeWithScrollShelves(shelf) scribe.newScroll(id = "scoped").seal(scribe) return scribe @@ -76,13 +76,11 @@ internal class PaymentService { @Serializable internal data class GatewayMeta(val retries: Int) -internal class RecordingShelf : Saver { - val events = mutableListOf() +internal class RecordingShelf : Archivist { + val events = mutableListOf() private val writes = Channel(Channel.UNLIMITED) - override val accepts get() = ScrollEntry::class - - override suspend fun write(event: ScrollEntry) { + override suspend fun write(event: Entry) { events += event writes.trySend(Unit) } @@ -97,31 +95,13 @@ internal class RecordingShelf : Saver { internal class BlockingShelf( private val gate: CompletableDeferred, private val firstWriteStarted: CompletableDeferred? = null, -) : Saver { - val events = mutableListOf() - private val writes = Channel(Channel.UNLIMITED) - - override val accepts get() = ScrollEntry::class - - override suspend fun write(event: ScrollEntry) { - firstWriteStarted?.complete(Unit) - gate.await() - events += event - writes.trySend(Unit) - } - - suspend fun awaitEvents(count: Int) { - repeat(count) { - writes.receive() - } - } -} - -internal class RecordingEntrySaver : EntrySaver { +) : Archivist { val events = mutableListOf() private val writes = Channel(Channel.UNLIMITED) override suspend fun write(event: Entry) { + firstWriteStarted?.complete(Unit) + gate.await() events += event writes.trySend(Unit) } @@ -131,4 +111,4 @@ internal class RecordingEntrySaver : EntrySaver { writes.receive() } } -} +} \ No newline at end of file diff --git a/testApp/README.md b/testApp/README.md index bb2c4b8..e130675 100644 --- a/testApp/README.md +++ b/testApp/README.md @@ -13,9 +13,9 @@ No server or local observability stack is required. - Map reads/removals before sealing - `seal(...)` snapshots and fail-styled scrolls (via data fields) - `Margin.header(...)` and `Margin.footer(...)` -- `EntrySaver` +- `Archivist` fanned out across multiple outputs - Channel overflow behavior through `DROP_OLDEST` -- Saver failure reporting through `hire(onSaver = ...)` +- Archivist failure reporting through `hire(onArchivist = ...)` - `retire()` and runtime re-hire - `onIgnition` wiring without intentionally crashing the app @@ -29,7 +29,7 @@ From the repository root: ``` The UI contains demo actions for quick scrolls, wide events, JSON serialization, queue -delivery, saver failures, and runtime shutdown. Each delivered `Entry` is +delivery, archivist failures, and runtime shutdown. Each delivered `Entry` is rendered as JSON and printed to stdout, while the most recent records remain visible in the in-app timeline. @@ -60,6 +60,6 @@ Useful fields include: - `scroll_id` - Scroll fields such as `tag`, `level`, `success`, `gateway`, `order_id`, `order_snapshot`, and `elapsed_ms` -The overflow scenario intentionally slows the console saver while using a small +The overflow scenario intentionally slows the console archivist while using a small dropping channel; fewer printed records than attempted quick scrolls demonstrates the configured overflow behavior. diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt index ed3fc3e..0743b3c 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt @@ -1,6 +1,6 @@ package scribe.demo.data -import com.rafambn.scribe.ScrollEntry +import com.rafambn.scribe.Entry import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull @@ -9,10 +9,10 @@ import kotlinx.serialization.json.jsonPrimitive typealias ConsoleRecord = Map fun consoleRecordFromEntry( - entry: ScrollEntry, + entry: Entry, demoName: String, platform: String, - saverType: String, + archivistType: String, appVersion: String, recordedAt: Long, ): ConsoleRecord { @@ -22,7 +22,7 @@ fun consoleRecordFromEntry( payload["demo_name"] = JsonPrimitive(stringField(entry, "demo_name") ?: demoName) payload["platform"] = JsonPrimitive(platform) payload["app_version"] = JsonPrimitive(appVersion) - payload["saver_type"] = JsonPrimitive(saverType) + payload["archivist_type"] = JsonPrimitive(archivistType) payload["scroll_id"] = JsonPrimitive(stringField(entry, "scroll_id") ?: "missing-scroll-id") stringField(entry, "message")?.let { payload["message"] = JsonPrimitive(it) } entry["order_id"]?.let { payload["order_id"] = it } diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt index 189c008..7fbfd2b 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt @@ -1,10 +1,10 @@ package scribe.demo.scribe -import com.rafambn.scribe.EntrySaver +import com.rafambn.scribe.Archivist import com.rafambn.scribe.Margin import com.rafambn.scribe.Scribe import com.rafambn.scribe.Scroll -import com.rafambn.scribe.ScrollEntry +import com.rafambn.scribe.Entry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -17,19 +17,19 @@ import scribe.demo.data.sampleImprint import scribe.demo.platformName import kotlin.time.Duration.Companion.milliseconds -class AppScribe(onRecord: (ScrollEntry) -> Unit) : Scribe() { +class AppScribe(onRecord: (Entry) -> Unit) : Scribe() { var overflowDelay: Boolean = false override val shelves = listOf( - EntrySaver { entry -> - if (entry is ScrollEntry && entry["tag"]?.jsonPrimitive?.contentOrNull == "saver_failure") { - error("Intentional saver failure from showcase demo") + Archivist { entry -> + if (entry["tag"]?.jsonPrimitive?.contentOrNull == "archivist_failure") { + error("Intentional archivist failure from showcase demo") } }, - EntrySaver { entry -> + Archivist { entry -> if (overflowDelay) delay(220.milliseconds) - if (entry is ScrollEntry) onRecord(entry) + onRecord(entry) }, ) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt index c13c22a..6dc238c 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt @@ -40,7 +40,7 @@ fun HomeContent( isRetired: Boolean, ignitionMessage: String, activeScrollIds: List, - saverErrors: List, + archivistErrors: List, lastRecord: String, timeline: List, onRunQuickScrollScenario: () -> Unit, @@ -50,9 +50,9 @@ fun HomeContent( onRunMarginScenario: () -> Unit, onRunJsonSerializationScenario: () -> Unit, onRunStringTemplateScenario: () -> Unit, - onRunEntrySaverScenario: () -> Unit, + onRunArchivistScenario: () -> Unit, onRunOverflowScenario: () -> Unit, - onRunSaverFailureScenario: () -> Unit, + onRunArchivistFailureScenario: () -> Unit, onRehireMainScribe: () -> Unit, onRunRetireScenario: () -> Unit, onRunPlanRetireScenario: () -> Unit, @@ -86,7 +86,7 @@ fun HomeContent( isRetired = isRetired, ignitionMessage = ignitionMessage, activeScrollIds = activeScrollIds, - saverErrors = saverErrors, + archivistErrors = archivistErrors, isBusy = isBusy, busyLabel = busyLabel, ) @@ -119,12 +119,12 @@ fun HomeContent( enabled = !isBusy, ) ActionGroup( - title = "Savers And Delivery", - description = "Use the saver types, queue overflow behavior, and saver error handling.", + title = "Archivists And Delivery", + description = "Use the archivist types, queue overflow behavior, and archivist error handling.", buttons = listOf( - "EntrySaver mixed flow" to onRunEntrySaverScenario, + "Archivist mixed flow" to onRunArchivistScenario, "Overflow demo" to onRunOverflowScenario, - "Saver failure demo" to onRunSaverFailureScenario, + "Archivist failure demo" to onRunArchivistFailureScenario, ), enabled = !isBusy, ) @@ -162,7 +162,7 @@ private fun HeroCard() { fontWeight = FontWeight.Bold, ) Text( - text = "Guided demos for quick scrolls, wide events, margins, queue delivery, and saver behavior. Every delivered record is printed to the console.", + text = "Guided demos for quick scrolls, wide events, margins, queue delivery, and archivist behavior. Every delivered record is printed to the console.", color = Color(0xFFE7ECEF), style = MaterialTheme.typography.bodyLarge, ) @@ -182,7 +182,7 @@ private fun StatusCard( isRetired: Boolean, ignitionMessage: String, activeScrollIds: List, - saverErrors: List, + archivistErrors: List, isBusy: Boolean, busyLabel: String, ) { @@ -206,9 +206,9 @@ private fun StatusCard( style = MaterialTheme.typography.bodyMedium, ) } - if (saverErrors.isNotEmpty()) { + if (archivistErrors.isNotEmpty()) { Text( - "Saver errors: ${saverErrors.joinToString()}", + "Archivist errors: ${archivistErrors.joinToString()}", style = MaterialTheme.typography.bodyMedium, color = Color(0xFF9C2F2F), ) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt index 0a7802e..4923741 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt @@ -23,7 +23,7 @@ fun HomeScreen() { isRetired = state.isRetired, ignitionMessage = state.ignitionMessage, activeScrollIds = state.activeScrollIds, - saverErrors = state.saverErrors, + archivistErrors = state.archivistErrors, lastRecord = state.lastRecord, timeline = state.timeline, onRunQuickScrollScenario = viewModel::runQuickScrollScenario, @@ -33,9 +33,9 @@ fun HomeScreen() { onRunMarginScenario = viewModel::runMarginScenario, onRunJsonSerializationScenario = viewModel::runJsonSerializationScenario, onRunStringTemplateScenario = viewModel::runStringTemplateScenario, - onRunEntrySaverScenario = viewModel::runEntrySaverScenario, + onRunArchivistScenario = viewModel::runArchivistScenario, onRunOverflowScenario = viewModel::runOverflowScenario, - onRunSaverFailureScenario = viewModel::runSaverFailureScenario, + onRunArchivistFailureScenario = viewModel::runArchivistFailureScenario, onRehireMainScribe = viewModel::rehireMainScribe, onRunRetireScenario = viewModel::runRetireScenario, onRunPlanRetireScenario = viewModel::runPlanRetireScenario, diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt index e33cc5e..21ada57 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt @@ -10,7 +10,7 @@ data class HomeState( val activeScrollIds: List = emptyList(), val lastRecord: String = "", val outputMessage: String = "Records are written to the application console.", - val saverErrors: List = emptyList(), + val archivistErrors: List = emptyList(), val timeline: List = emptyList(), val ignitionMessage: String = "The onIgnition hook is wired, but the demo does not crash itself to trigger it.", ) \ No newline at end of file diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt index 53f7c5b..23aee40 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt @@ -1,6 +1,6 @@ package scribe.demo.ui -import com.rafambn.scribe.ScrollEntry +import com.rafambn.scribe.Entry import com.rafambn.scribe.Scribe import com.rafambn.scribe.Scroll import com.rafambn.scribe.id @@ -58,9 +58,9 @@ class HomeViewModel { appScribe.hire( scope = scope, channel = Channel(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST), - onSaver = { saver, entry, error -> - appendSaverError( - "Saver failure in ${saver::class.simpleName ?: "Saver"} for ${entryKind()}: ${error.message ?: error}", + onArchiveFailure = { archivist, entry, error -> + appendArchivistError( + "Archivist failure in ${archivist::class.simpleName ?: "Archivist"} for ${entryKind()}: ${error.message ?: error}", ) }, ) @@ -72,7 +72,7 @@ class HomeViewModel { message = "Started checkout for premium customer", level = "INFO", ) - updateStatus("Ran a quick scroll: one immediately sealed event printed through EntrySaver.") + updateStatus("Ran a quick scroll: one immediately sealed event printed through Archivist.") } fun runSecondQuickScrollScenario() = launchScenario("Second quick scroll demo") { @@ -194,18 +194,18 @@ class HomeViewModel { updateStatus("Ran JSON serialization demo with a nested object payload for console inspection.") } - fun runEntrySaverScenario() = launchScenario("Unified EntrySaver demo") { + fun runArchivistScenario() = launchScenario("Unified Archivist demo") { emitQuickScroll( tag = "auth", message = "Session accepted for staff dashboard", level = "INFO", ) val scroll = openScroll(appScribe, id = "session-audit") - scroll["demo_name"] = JsonPrimitive("entry_saver_demo") + scroll["demo_name"] = JsonPrimitive("entry_archivist_demo") scroll["role"] = JsonPrimitive("support") scroll["elevated_access"] = JsonPrimitive(true) sealScroll(scroll, appScribe) - updateStatus("Ran two scrolls through one EntrySaver path.") + updateStatus("Ran two scrolls through one Archivist path.") } fun runOverflowScenario() = launchScenario("Overflow demo") { @@ -233,13 +233,13 @@ class HomeViewModel { updateStatus("Ran overflow demo with Channel(..., onBufferOverflow = DROP_OLDEST).") } - fun runSaverFailureScenario() = launchScenario("Saver error demo") { + fun runArchivistFailureScenario() = launchScenario("Archivist error demo") { emitQuickScroll( - tag = "saver_failure", - message = "Intentional saver failure probe", + tag = "archivist_failure", + message = "Intentional archivist failure probe", level = "WARN", ) - updateStatus("Saver failure demo ran; onSaver callback captures the injected failure.") + updateStatus("Archivist failure demo ran; onArchivist callback captures the injected failure.") } fun runRetireScenario() = launchScenario("retire() demo") { @@ -309,9 +309,9 @@ class HomeViewModel { appScribe.hire( channel = Channel(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST), scope = scope, - onSaver = { saver, entry, error -> - appendSaverError( - "Saver failure in ${saver::class.simpleName ?: "Saver"} for ${entryKind()}: ${error.message ?: error}", + onArchiveFailure = { archivist, entry, error -> + appendArchivistError( + "Archivist failure in ${archivist::class.simpleName ?: "Archivist"} for ${entryKind()}: ${error.message ?: error}", ) }, ) @@ -351,12 +351,12 @@ class HomeViewModel { } } - private fun handleRecord(entry: ScrollEntry) { + private fun handleRecord(entry: Entry) { val record = consoleRecordFromEntry( entry = entry, demoName = "shared_session", platform = platform, - saverType = "EntrySaver", + archivistType = "Archivist", appVersion = appVersion, recordedAt = currentEpochMillis(), ) @@ -371,7 +371,7 @@ class HomeViewModel { ) } appendTimeline( - title = "${payloadEventKind(record)} via EntrySaver", + title = "${payloadEventKind(record)} via Archivist", detail = "${recordSummary(record)}. Printed to console.", payload = payload, success = true, @@ -386,15 +386,15 @@ class HomeViewModel { } } - private fun appendSaverError(message: String) { + private fun appendArchivistError(message: String) { println(message) _state.update { it.copy( - saverErrors = listOf(message) + it.saverErrors.take(5), + archivistErrors = listOf(message) + it.archivistErrors.take(5), ) } appendTimeline( - title = "Saver failure captured", + title = "Archivist failure captured", detail = message, payload = "", success = false, From 3f1a486a6db7eb0538d8e77a73374a296bd25f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Fri, 7 Aug 2026 11:44:53 -0300 Subject: [PATCH 03/11] Refactor: rename `shelves` to `archivists` in `Scribe` and update references across codebase --- scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt | 4 ++-- .../kotlin/com/rafambn/scribe/ScribeTestFixtures.kt | 4 ++-- .../src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 93619b1..4e373b5 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -28,7 +28,7 @@ abstract class Scribe { /** * Archivists receiving structured logs emitted by this instance. */ - protected abstract val shelves: List + protected abstract val archivists: List /** * Fields copied into every [Scroll] created by this instance. @@ -64,7 +64,7 @@ abstract class Scribe { channel: Channel, onArchiveFailure: ((archivist: Archivist, entry: Entry, error: Throwable) -> Unit)? = null, ) { - val configuredShelves = shelves + val configuredShelves = archivists require(configuredShelves.isNotEmpty()) { "At least one shelf is required." } check(activeQueue == null) { "Scribe runtime is already active. Call retire() first." } check(processorJob?.isActive != true) { "Scribe is still retiring. Wait for pending delivery to finish." } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index 317d583..3c5c71d 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -22,7 +22,7 @@ internal fun scribeWithScrollShelves( val configuredImprint = imprint val configuredMargins = margins return object : Scribe() { - override val shelves: List = configuredShelves + override val archivists: List = configuredShelves override val imprint: Map = configuredImprint override val margins: Margin? = configuredMargins }.also { @@ -41,7 +41,7 @@ internal fun scribeWithArchivists( val configuredImprint = imprint val configuredMargins = margins return object : Scribe() { - override val shelves: List = configuredShelves + override val archivists: List = configuredShelves override val imprint: Map = configuredImprint override val margins: Margin? = configuredMargins }.also { diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt index 7fbfd2b..c5d486b 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt @@ -21,7 +21,7 @@ class AppScribe(onRecord: (Entry) -> Unit) : Scribe() { var overflowDelay: Boolean = false - override val shelves = listOf( + override val archivists = listOf( Archivist { entry -> if (entry["tag"]?.jsonPrimitive?.contentOrNull == "archivist_failure") { error("Intentional archivist failure from showcase demo") From 2f8fe3c485005b59311ec2e3c17ec4dfe45f5e82 Mon Sep 17 00:00:00 2001 From: rafambn Date: Sat, 8 Aug 2026 17:27:33 -0300 Subject: [PATCH 04/11] Add performance benchmarks and throughput tests for Scribe --- README.md | 8 ++ .../rafambn/scribe/ScribeThroughputTest.kt | 79 +++++++++++++ .../scribe/ScribeFileThroughputTest.kt | 104 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt create mode 100644 scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt diff --git a/README.md b/README.md index 239d976..efe77f6 100644 --- a/README.md +++ b/README.md @@ -99,3 +99,11 @@ Choose the archivist that matches your output flow: ```kotlin val scrollArchivist = Archivist { scroll -> println(scroll) } ``` + +## Performance + +Scribe is designed for high-throughput and thread-safe concurrent logging. + +Benchmark results (measured on JVM): +- **In-memory ingestion**: ~830,000 logs/sec (Concurrent) +- **Safe File Writing**: ~130,000 logs/sec (Concurrent, verified no corruption) diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt new file mode 100644 index 0000000..6c91414 --- /dev/null +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt @@ -0,0 +1,79 @@ +package com.rafambn.scribe + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.TimeSource + +class ScribeThroughputTest { + + private class NoOpArchivist : Archivist { + var count = 0 + override suspend fun write(event: Entry) { + count++ + } + } + + @Test + fun measure_sequential_throughput() = runSuspend { + val archivist = NoOpArchivist() + val scribe = scribeWithScrollShelves(archivist, channel = Channel(Channel.UNLIMITED)) + val iterations = 100_000 + + val timeSource = TimeSource.Monotonic + val start = timeSource.markNow() + + repeat(iterations) { + val scroll = scribe.newScroll() + scroll["index"] = JsonPrimitive(it) + scroll.seal(scribe) + } + + scribe.retire() + val duration = start.elapsedNow() + + println("Sequential Throughput:") + printResults(iterations, duration) + } + + @Test + fun measure_concurrent_throughput() = runSuspend { + val archivist = NoOpArchivist() + val scribe = scribeWithScrollShelves(archivist, channel = Channel(Channel.UNLIMITED)) + val iterations = 100_000 + val coroutines = 10 + + val timeSource = TimeSource.Monotonic + val start = timeSource.markNow() + + coroutineScope { + repeat(coroutines) { c -> + launch(Dispatchers.Default) { + repeat(iterations / coroutines) { i -> + val scroll = scribe.newScroll() + scroll["c"] = JsonPrimitive(c) + scroll["i"] = JsonPrimitive(i) + scroll.seal(scribe) + } + } + } + } + + scribe.retire() + val duration = start.elapsedNow() + + println("Concurrent Throughput ($coroutines coroutines):") + printResults(iterations, duration) + } + + private fun printResults(iterations: Int, duration: Duration) { + val seconds = duration.inWholeMicroseconds / 1_000_000.0 + val opsPerSec = iterations / seconds + println(" Completed $iterations ops in $duration") + println(" Throughput: ${opsPerSec.toInt()} ops/sec") + } +} diff --git a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt new file mode 100644 index 0000000..f4e5598 --- /dev/null +++ b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt @@ -0,0 +1,104 @@ +package com.rafambn.scribe + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import java.io.File +import java.io.FileWriter +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.TimeSource + +class ScribeFileThroughputTest { + + private lateinit var testFile: File + private val json = Json { encodeDefaults = true } + + private class FileArchivist(file: File, private val json: Json) : Archivist { + private val writer = FileWriter(file).buffered() + + override suspend fun write(event: Entry) { + val line = json.encodeToString(event) + writer.write(line) + writer.newLine() + } + + fun close() { + writer.flush() + writer.close() + } + } + + @BeforeTest + fun setup() { + testFile = File.createTempFile("scribe-throughput", ".log") + } + + @AfterTest + fun cleanup() { + if (testFile.exists()) { + testFile.delete() + } + } + + @Test + fun measure_concurrent_file_throughput() = runBlocking { + val archivist = FileArchivist(testFile, json) + val scribe = scribeWithScrollShelves(archivist, channel = Channel(Channel.UNLIMITED)) + val iterations = 50_000 + val coroutines = 10 + + println("Starting File Load Test: writing $iterations entries to ${testFile.absolutePath} using $coroutines coroutines...") + + val timeSource = TimeSource.Monotonic + val start = timeSource.markNow() + + coroutineScope { + repeat(coroutines) { c -> + launch(Dispatchers.Default) { + repeat(iterations / coroutines) { i -> + val scroll = scribe.newScroll() + scroll["coroutine"] = JsonPrimitive(c) + scroll["index"] = JsonPrimitive(i) + scroll["payload"] = JsonPrimitive("Some repetitive logging payload to simulate load " + i) + scroll.seal(scribe) + } + } + } + } + + scribe.retire() + archivist.close() + + val duration = start.elapsedNow() + + // Verification + val lines = testFile.readLines() + assertEquals(iterations, lines.size, "Line count mismatch. Possible data loss.") + + // Check for corruption (ensure each line is a valid JSON and belongs to Scribe) + lines.forEach { line -> + assertTrue(line.startsWith("{") && line.endsWith("}"), "Interleaved or corrupt line: $line") + assertTrue(line.contains("scroll_id"), "Metadata missing in line: $line") + } + + println("File Throughput Results:") + printResults(iterations, duration) + } + + private fun printResults(iterations: Int, duration: Duration) { + val seconds = duration.inWholeMicroseconds / 1_000_000.0 + val opsPerSec = iterations / seconds + println(" Completed $iterations file writes in $duration") + println(" Throughput: ${opsPerSec.toInt()} ops/sec") + println(" Final file size: ${testFile.length() / 1024} KB") + } +} From 48e4a8a20c0f2ae10563e6c1629f6dee5dc0f355 Mon Sep 17 00:00:00 2001 From: rafambn Date: Mon, 10 Aug 2026 16:27:41 -0300 Subject: [PATCH 05/11] Refactor Scribe lifecycle controls --- .../kotlin/com/rafambn/scribe/Scribe.kt | 202 ++++++++++++------ .../scribe/ScribeConcurrencyAndScrollTest.kt | 7 +- .../scribe/ScribeDeliveryRetireTest.kt | 76 +++++-- .../scribe/ScribeIndependentLifecycleTest.kt | 66 ++++++ .../com/rafambn/scribe/ScribeTestFixtures.kt | 24 ++- .../rafambn/scribe/ScribeThroughputTest.kt | 13 +- .../scribe/ScribeFileThroughputTest.kt | 7 +- 7 files changed, 297 insertions(+), 98 deletions(-) create mode 100644 scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeIndependentLifecycleTest.kt diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 4e373b5..cb123c1 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -1,17 +1,25 @@ package com.rafambn.scribe import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** * Independent structured log writer that creates [Scroll]s and dispatches [Entry] snapshots to configured archivists. @@ -20,16 +28,29 @@ import kotlinx.serialization.json.JsonPrimitive * * ``` * object AppScribe : Scribe() { - * override val shelves = listOf(Archivist { entry -> println(entry) }) + * override val bufferCapacity = 256 + * override val bufferOverflow = BufferOverflow.DROP_OLDEST + * override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + * override val archivists = listOf(Archivist { entry -> println(entry) }) * } * ``` */ +@OptIn(ExperimentalAtomicApi::class) abstract class Scribe { /** * Archivists receiving structured logs emitted by this instance. */ protected abstract val archivists: List + /** Maximum number of entries retained by this instance's private buffer. */ + protected abstract val bufferCapacity: Int + + /** Overflow behavior used when this instance's private buffer is full. */ + protected abstract val bufferOverflow: BufferOverflow + + /** Callback invoked when an archivist fails to write an entry. */ + protected abstract val onArchiveFailure: ((archivist: Archivist, entry: Entry, error: Throwable) -> Unit)? + /** * Fields copied into every [Scroll] created by this instance. */ @@ -48,58 +69,73 @@ abstract class Scribe { */ protected open val onIgnition: ((Throwable) -> Unit)? = null - private var activeQueue: Channel? = null - private var processorJob: Job? = null - private var ignitionInstalled: Boolean = false + private val queue: Channel by lazy { + Channel( + capacity = bufferCapacity, + onBufferOverflow = bufferOverflow, + ) + } + private val ownedScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val intakeOpen = AtomicBoolean(true) + private val processingEnabled = MutableStateFlow(false) + private val retiring = AtomicBoolean(false) + private val ignitionInstalled = AtomicBoolean(false) + private val retirementCompleted = CompletableDeferred() + private val processorJob: Job by lazy { + ownedScope.launch { + try { + for (entry in queue) { + processingEnabled.first { it } + archive(archivists, entry) + } + } finally { + processingEnabled.value = false + } + } + } + + /** Whether new entries are currently accepted into the private buffer. */ + val isIntakeOpen: Boolean + get() = intakeOpen.load() && !retiring.load() + + /** Whether buffered entries are currently being processed. */ + val isProcessing: Boolean + get() = processingEnabled.value /** - * Starts delivery for this runtime instance. + * Starts or resumes delivery from this instance's private buffer. * - * The provided [channel] becomes disposable and transfers ownership to this instance. - * This instance closes the channel when the processor completes or when [retire] is called. - * Create a fresh channel for each call to this method. + * Entries can be accepted before this method is called. Calling this method while processing + * is already active has no effect. After [dismiss], this method resumes processing. */ - fun hire( - scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), - channel: Channel, - onArchiveFailure: ((archivist: Archivist, entry: Entry, error: Throwable) -> Unit)? = null, - ) { - val configuredShelves = archivists - require(configuredShelves.isNotEmpty()) { "At least one shelf is required." } - check(activeQueue == null) { "Scribe runtime is already active. Call retire() first." } - check(processorJob?.isActive != true) { "Scribe is still retiring. Wait for pending delivery to finish." } + fun hire() { + val configuredArchivists = archivists + require(configuredArchivists.isNotEmpty()) { "At least one archivist is required." } + check(!retiring.load()) { "This Scribe has been retired." } val exceptionHandler = onIgnition - if (!ignitionInstalled && exceptionHandler != null) { + if (exceptionHandler != null && ignitionInstalled.compareAndSet(expectedValue = false, newValue = true)) { installUncaughtExceptionHandler(exceptionHandler) - ignitionInstalled = true } - activeQueue = channel - val createdProcessor = scope.launch { - for (entry in channel) { - configuredShelves.forEach { archivist -> - try { - archivist.write(entry) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - try { - onArchiveFailure?.invoke(archivist, entry, e) - } catch (_: Throwable) { - // Ignore callback failures to keep delivery alive. - } - } - } - } - } - processorJob = createdProcessor - createdProcessor.invokeOnCompletion { - channel.close() - if (processorJob === createdProcessor) { - processorJob = null - } + + val processor = processorJob + processingEnabled.value = true + if (processor.isCompleted) { + processingEnabled.value = false + error("The Scribe processor has terminated and cannot be restarted.") } } + /** Allows new entries to be accepted into the private buffer. */ + fun openIntake() { + check(!retiring.load()) { "This Scribe has been retired." } + intakeOpen.store(true) + } + + /** Stops accepting new entries without changing processing of entries already buffered. */ + fun closeIntake() { + intakeOpen.store(false) + } + /** * Creates a new scroll, optionally with a custom unique [id]. * @@ -117,23 +153,39 @@ abstract class Scribe { } /** - * Stops accepting structured logs, closes the delivery channel, and waits for queued logs to finish delivery. - * - * The channel passed to [hire] is closed and must not be reused. - * After this call completes, you may call [hire] again with a fresh channel. - * - * If called from within the processor coroutine (e.g., from a archivist), - * this function returns immediately without waiting to avoid deadlocks. + * Cooperatively pauses delivery, preserving queued entries. + * Intake remains independently controlled by [openIntake] and [closeIntake]. Call [hire] to + * resume processing. + */ + fun dismiss() { + processingEnabled.value = false + } + + /** + * Permanently closes intake, drains every accepted entry, and releases the owned runtime. + * This is the terminal lifecycle operation; neither intake nor processing can restart afterward. */ suspend fun retire() { - val queue = activeQueue - val runningProcessor = processorJob - if (queue == null && runningProcessor == null) return - activeQueue = null - queue?.close() val callerJob = currentCoroutineContext()[Job] - if (runningProcessor != null && !isProcessorFamily(runningProcessor, callerJob)) { - runningProcessor.join() + check(!isProcessorFamily(processorJob, callerJob)) { + "retire() cannot be called from an archivist; request it from the lifecycle owner." + } + + if (!retiring.compareAndSet(expectedValue = false, newValue = true)) { + retirementCompleted.await() + return + } + + try { + intakeOpen.store(false) + processingEnabled.value = true + queue.close() + processorJob.join() + ownedScope.cancel() + retirementCompleted.complete(Unit) + } catch (error: Throwable) { + retirementCompleted.completeExceptionally(error) + throw error } } @@ -156,19 +208,29 @@ abstract class Scribe { margins?.footer(scroll) } - private fun requireActiveQueue(): Channel { - return activeQueue ?: throw IllegalStateException("This Scribe runtime is not active. Call hire(...) first.") - } - - fun enqueue(entry: Entry) { - requireActiveQueue().trySendBlocking(entry) + internal fun enqueue(entry: Entry): Boolean { + if (!isIntakeOpen) return false + return queue.trySend(entry).isSuccess } - private fun Channel.trySendBlocking(element: E) { - val result = trySend(element) - if (result.isSuccess) return - runBlocking { - runCatching { send(element) } + private suspend fun archive( + configuredArchivists: List, + entry: Entry, + ) = coroutineScope { + configuredArchivists.forEach { archivist -> + launch { + try { + archivist.write(entry) + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() + } catch (e: Throwable) { + try { + onArchiveFailure?.invoke(archivist, entry, e) + } catch (_: Throwable) { + // Ignore callback failures to keep delivery alive. + } + } + } } } -} \ No newline at end of file +} diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt index 90e5926..eeefe6e 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt @@ -1,6 +1,7 @@ package com.rafambn.scribe import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -14,7 +15,11 @@ class ScribeConcurrencyAndScrollTest { fun scroll_seal_supports_high_throughput_concurrent_writes() { runSuspend { val shelf = RecordingShelf() - val scribe = scribeWithScrollShelves(shelf, channel = Channel(Channel.UNLIMITED)) + val scribe = scribeWithScrollShelves( + shelf, + bufferCapacity = Channel.UNLIMITED, + bufferOverflow = BufferOverflow.SUSPEND, + ) coroutineScope { repeat(1_000) { index -> diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt index 1b8bbea..8871057 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds @@ -64,6 +63,33 @@ class ScribeDeliveryRetireTest { } } + @Test + fun archivists_process_the_same_entry_in_parallel() { + runSuspend { + val release = CompletableDeferred() + val firstStarted = CompletableDeferred() + val secondStarted = CompletableDeferred() + val first = Archivist { + firstStarted.complete(Unit) + release.await() + } + val second = Archivist { + secondStarted.complete(Unit) + release.await() + } + val scribe = scribeWithArchivists(listOf(first, second)) + + scribe.newScroll(id = "parallel").seal(scribe) + withTimeout(2_000.milliseconds) { + firstStarted.await() + secondStarted.await() + } + + release.complete(Unit) + scribe.retire() + } + } + @Test fun scroll_events_reach_all_configured_archivists() { runSuspend { @@ -94,7 +120,8 @@ class ScribeDeliveryRetireTest { val shelf = BlockingShelf(gate) val scribe = scribeWithScrollShelves( shelf, - channel = Channel(capacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST), + bufferCapacity = 4, + bufferOverflow = BufferOverflow.DROP_OLDEST, ) scribe.newScroll(id = "slow").seal(scribe) @@ -143,7 +170,7 @@ class ScribeDeliveryRetireTest { } @Test - fun retire_waits_for_inflight_delivery() { + fun dismiss_returns_while_inflight_delivery_finishes_cooperatively() { runSuspend { val gate = CompletableDeferred() val firstWriteStarted = CompletableDeferred() @@ -152,26 +179,33 @@ class ScribeDeliveryRetireTest { scribe.newScroll(id = "in-flight").seal(scribe) firstWriteStarted.await() - val retireScope = CoroutineScope(Dispatchers.Default) - val retireJob = retireScope.launch { scribe.retire() } - delay(50.milliseconds) - assertFalse(retireJob.isCompleted) + scribe.dismiss() + assertFalse(scribe.isProcessing) + assertTrue(shelf.events.isEmpty()) + gate.complete(Unit) - withTimeout(2_000.milliseconds) { retireJob.join() } - retireScope.cancel() + shelf.awaitEvents(1) assertEquals("in-flight", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) + scribe.retire() } } @Test - fun seal_throws_after_retire() { + fun dismiss_keeps_intake_open_and_rehire_processes_buffered_entries() { runSuspend { - val scribe = scribeWithScrollShelves(RecordingShelf()) + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf) + + scribe.dismiss() + assertFalse(scribe.isProcessing) + scribe.newScroll(id = "after").seal(scribe) + delay(50.milliseconds) + assertTrue(shelf.events.isEmpty()) + scribe.hire() + shelf.awaitEvents(1) + assertEquals("after", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) scribe.retire() - assertFailsWith { - scribe.newScroll(id = "after").seal(scribe) - } } } @@ -200,12 +234,12 @@ class ScribeDeliveryRetireTest { } @Test - fun retire_called_from_archivist_does_not_deadlock() { + fun dismiss_called_from_archivist_does_not_deadlock() { runSuspend { val retired = CompletableDeferred() lateinit var scribe: Scribe val archivist = Archivist { - scribe.retire() + scribe.dismiss() retired.complete(Unit) } scribe = scribeWithArchivists(shelves = listOf(archivist)) @@ -216,14 +250,14 @@ class ScribeDeliveryRetireTest { } @Test - fun retire_called_from_archivist_child_coroutine_does_not_deadlock() { + fun dismiss_called_from_archivist_child_coroutine_does_not_deadlock() { runSuspend { val retired = CompletableDeferred() lateinit var scribe: Scribe val archivist = Archivist { coroutineScope { launch { - scribe.retire() + scribe.dismiss() retired.complete(Unit) } } @@ -240,6 +274,7 @@ class ScribeDeliveryRetireTest { runSuspend { val events = mutableListOf() val errors = mutableListOf() + val failureReported = CompletableDeferred() val failingArchivist = Archivist { throw IllegalStateException("boom") } val recordingArchivist = RecordingShelf() val scribe = scribeWithArchivists( @@ -247,11 +282,13 @@ class ScribeDeliveryRetireTest { onArchivist = { _, entry, error -> events += entry errors += error + failureReported.complete(Unit) }, ) scribe.newScroll(id = "error-1").seal(scribe) recordingArchivist.awaitEvents(1) + failureReported.await() scribe.retire() assertEquals(1, recordingArchivist.events.size) @@ -291,7 +328,8 @@ class ScribeDeliveryRetireTest { val cancelingArchivist = Archivist { throw CancellationException("cancel-delivery") } val scribe = scribeWithArchivists( shelves = listOf(cancelingArchivist), - channel = Channel(capacity = 16), + bufferCapacity = 16, + bufferOverflow = BufferOverflow.SUSPEND, onArchivist = { _, _, error -> reportedErrors += error }, diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeIndependentLifecycleTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeIndependentLifecycleTest.kt new file mode 100644 index 0000000..bfe3341 --- /dev/null +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeIndependentLifecycleTest.kt @@ -0,0 +1,66 @@ +package com.rafambn.scribe + +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +class ScribeIndependentLifecycleTest { + @Test + fun input_starts_enabled_while_processing_starts_paused() = runSuspend { + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf, startProcessing = false) + + assertTrue(scribe.isIntakeOpen) + assertFalse(scribe.isProcessing) + + scribe.newScroll(id = "buffered-before-hire").seal(scribe) + delay(50.milliseconds) + assertTrue(shelf.events.isEmpty()) + + scribe.hire() + shelf.awaitEvents(1) + assertEquals( + "buffered-before-hire", + shelf.events.single()["scroll_id"]?.jsonPrimitive?.content, + ) + scribe.retire() + } + + @Test + fun input_can_be_disabled_and_enabled_without_stopping_processing() = runSuspend { + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf) + + scribe.closeIntake() + assertFalse(scribe.isIntakeOpen) + assertTrue(scribe.isProcessing) + scribe.newScroll(id = "rejected").seal(scribe) + + scribe.openIntake() + scribe.newScroll(id = "accepted").seal(scribe) + shelf.awaitEvents(1) + + assertEquals("accepted", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) + scribe.retire() + } + + @Test + fun retire_drains_entries_even_when_job_is_dismissed() = runSuspend { + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf, startProcessing = false) + + scribe.newScroll(id = "first").seal(scribe) + scribe.newScroll(id = "second").seal(scribe) + + withTimeout(2_000.milliseconds) { scribe.retire() } + + assertEquals(listOf("first", "second"), shelf.events.map { it["scroll_id"]?.jsonPrimitive?.content }) + assertFalse(scribe.isIntakeOpen) + assertFalse(scribe.isProcessing) + } +} diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index 3c5c71d..27138f3 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -14,19 +14,26 @@ internal val UUID_REGEX = internal fun scribeWithScrollShelves( vararg shelves: Archivist, imprint: Map = emptyMap(), - channel: Channel = Channel(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST), + bufferCapacity: Int = 256, + bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST, onArchivist: (archivist: Archivist, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, margins: Margin? = null, + startProcessing: Boolean = true, ): Scribe { val configuredShelves = shelves.toList() val configuredImprint = imprint val configuredMargins = margins + val configuredBufferCapacity = bufferCapacity + val configuredBufferOverflow = bufferOverflow return object : Scribe() { override val archivists: List = configuredShelves + override val bufferCapacity: Int = configuredBufferCapacity + override val bufferOverflow: BufferOverflow = configuredBufferOverflow + override val onArchiveFailure = onArchivist override val imprint: Map = configuredImprint override val margins: Margin? = configuredMargins }.also { - it.hire(channel = channel, onArchiveFailure = onArchivist) + if (startProcessing) it.hire() } } @@ -34,18 +41,25 @@ internal fun scribeWithArchivists( shelves: List, imprint: Map = emptyMap(), margins: Margin? = null, - channel: Channel = Channel(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST), + bufferCapacity: Int = 256, + bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST, onArchivist: (archivist: Archivist, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, + startProcessing: Boolean = true, ): Scribe { val configuredShelves = shelves val configuredImprint = imprint val configuredMargins = margins + val configuredBufferCapacity = bufferCapacity + val configuredBufferOverflow = bufferOverflow return object : Scribe() { override val archivists: List = configuredShelves + override val bufferCapacity: Int = configuredBufferCapacity + override val bufferOverflow: BufferOverflow = configuredBufferOverflow + override val onArchiveFailure = onArchivist override val imprint: Map = configuredImprint override val margins: Margin? = configuredMargins }.also { - it.hire(channel = channel, onArchiveFailure = onArchivist) + if (startProcessing) it.hire() } } @@ -111,4 +125,4 @@ internal class BlockingShelf( writes.receive() } } -} \ No newline at end of file +} diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt index 6c91414..0a78e0a 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt @@ -1,6 +1,7 @@ package com.rafambn.scribe import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -21,7 +22,11 @@ class ScribeThroughputTest { @Test fun measure_sequential_throughput() = runSuspend { val archivist = NoOpArchivist() - val scribe = scribeWithScrollShelves(archivist, channel = Channel(Channel.UNLIMITED)) + val scribe = scribeWithScrollShelves( + archivist, + bufferCapacity = Channel.UNLIMITED, + bufferOverflow = BufferOverflow.SUSPEND, + ) val iterations = 100_000 val timeSource = TimeSource.Monotonic @@ -43,7 +48,11 @@ class ScribeThroughputTest { @Test fun measure_concurrent_throughput() = runSuspend { val archivist = NoOpArchivist() - val scribe = scribeWithScrollShelves(archivist, channel = Channel(Channel.UNLIMITED)) + val scribe = scribeWithScrollShelves( + archivist, + bufferCapacity = Channel.UNLIMITED, + bufferOverflow = BufferOverflow.SUSPEND, + ) val iterations = 100_000 val coroutines = 10 diff --git a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt index f4e5598..19db4af 100644 --- a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt +++ b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt @@ -1,6 +1,7 @@ package com.rafambn.scribe import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -52,7 +53,11 @@ class ScribeFileThroughputTest { @Test fun measure_concurrent_file_throughput() = runBlocking { val archivist = FileArchivist(testFile, json) - val scribe = scribeWithScrollShelves(archivist, channel = Channel(Channel.UNLIMITED)) + val scribe = scribeWithScrollShelves( + archivist, + bufferCapacity = Channel.UNLIMITED, + bufferOverflow = BufferOverflow.SUSPEND, + ) val iterations = 50_000 val coroutines = 10 From 3968919e2400dc601738013afca14d3ef3bc074f Mon Sep 17 00:00:00 2001 From: rafambn Date: Mon, 10 Aug 2026 16:28:44 -0300 Subject: [PATCH 06/11] Add SLF4J integration and lifecycle documentation --- README.md | 50 ++++- docs/api-concepts.md | 67 ++++--- docs/console-showcase.md | 4 +- docs/getting-started.md | 33 ++-- docs/index.md | 2 +- docs/lifecycle-and-delivery.md | 141 +++++--------- gradle/libs.versions.toml | 4 + scribe-slf4j/build.gradle.kts | 55 ++++++ .../rafambn/scribe/slf4j/NamedScribeLogger.kt | 54 ++++++ .../com/rafambn/scribe/slf4j/ScribeBackend.kt | 6 + .../scribe/slf4j/ScribeLoggerFactory.kt | 13 ++ .../rafambn/scribe/slf4j/ScribeLoggingCall.kt | 43 +++++ .../scribe/slf4j/ScribeServiceProvider.kt | 85 +++++++++ .../com/rafambn/scribe/slf4j/Slf4jScribe.kt | 54 ++++++ .../org.slf4j.spi.SLF4JServiceProvider | 1 + .../rafambn/scribe/slf4j/ScribeSLF4JTest.kt | 175 ++++++++++++++++++ settings.gradle.kts | 1 + testApp/README.md | 6 +- .../kotlin/scribe/demo/scribe/AppScribe.kt | 9 +- .../kotlin/scribe/demo/ui/HomeContent.kt | 8 +- .../kotlin/scribe/demo/ui/HomeViewModel.kt | 61 +++--- 21 files changed, 678 insertions(+), 194 deletions(-) create mode 100644 scribe-slf4j/build.gradle.kts create mode 100644 scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt create mode 100644 scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeBackend.kt create mode 100644 scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggerFactory.kt create mode 100644 scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggingCall.kt create mode 100644 scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt create mode 100644 scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/Slf4jScribe.kt create mode 100644 scribe-slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider create mode 100644 scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt diff --git a/README.md b/README.md index efe77f6..e063acc 100644 --- a/README.md +++ b/README.md @@ -52,17 +52,20 @@ kotlin { ## Usage -Create a `Scribe` object, hire its runtime, and emit a scroll: +Create a `Scribe` object, start processing its private buffer, and emit a scroll: ```kotlin object AppScribe : Scribe() { - override val shelves: List = listOf( + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = listOf( Archivist { scroll -> println(scroll) } ) } -AppScribe.hire(channel = Channel(capacity = 256)) +AppScribe.hire() val scroll = AppScribe.newScroll() scroll["tag"] = JsonPrimitive("payments") @@ -75,7 +78,10 @@ Use a scroll when you need shared context for a longer flow: ```kotlin object BillingScribe : Scribe() { - override val shelves: List = listOf( + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = listOf( Archivist { scroll -> println(scroll) } ) override val imprint = mapOf( @@ -83,7 +89,7 @@ object BillingScribe : Scribe() { "environment" to JsonPrimitive("production"), ) } -BillingScribe.hire(channel = Channel(capacity = 256)) +BillingScribe.hire() val scroll = BillingScribe.newScroll(id = "checkout-42") scroll["gateway"] = JsonPrimitive("stripe") @@ -94,6 +100,40 @@ scroll.seal(BillingScribe) Each `Scribe` object has independent configuration and delivery lifecycle. A `Scroll` is a mutable JSON-element map initialized by `newScroll(...)`; pass the runtime that should enrich and deliver it to `scroll.seal(scribe)`. Each `seal(...)` call emits a separate snapshot of the scroll data. +## SLF4J + +For JVM applications, add the SLF4J provider: + +```kotlin +dependencies { + implementation("com.rafambn:scribe-slf4j:0.5.0") +} +``` + +Select exactly one application-wide backend with `@ScribeBackend`: + +```kotlin +@ScribeBackend +object AppScribe : Slf4jScribe() { + override val bufferCapacity = 1_024 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists = listOf( + Archivist { entry -> println(entry) }, + ) + + override fun isEnabled( + loggerName: String, + level: Level, + marker: Marker?, + ): Boolean = level.toInt() >= Level.INFO.toInt() +} +``` + +The provider discovers the annotated backend once on the first SLF4J access and registers a JVM shutdown hook. Intake starts open, so early calls accumulate in the private buffer; the application calls `AppScribe.hire()` when processing should begin. The shutdown hook retires the Scribe and drains accepted entries automatically. Initialization fails with a descriptive error when no backend is present, multiple backends are annotated, or the annotation is not placed on a Kotlin object extending `Slf4jScribe`. + +`scribe-slf4j` is a standalone SLF4J provider. Do not include another provider such as `logback-classic` in the same runtime classpath. + Choose the archivist that matches your output flow: ```kotlin diff --git a/docs/api-concepts.md b/docs/api-concepts.md index 3563fd3..a43754c 100644 --- a/docs/api-concepts.md +++ b/docs/api-concepts.md @@ -15,7 +15,10 @@ Scribe models logging with structured scroll events: - `extend(scroll)`: copies missing keys from another scroll into this one - `append(key, scroll)`: nests a scroll as a JSON object under the given key - `Margin`: hook for writing fields at open/close boundaries -- `hire(channel = ..., scope = ..., onArchivist = ...)`: starts delivery over your channel configuration +- `hire()`: starts or resumes processing of the private buffer +- `openIntake()` / `closeIntake()`: independently control whether new entries are accepted +- `dismiss()`: requests a cooperative job pause while preserving buffered entries +- `retire()`: permanently closes intake and drains the buffer ## `Scribe` @@ -32,15 +35,19 @@ Define runtime configuration with overridden properties: ```kotlin object CheckoutScribe : Scribe() { - override val shelves: List = listOf(Archivist { entry -> println(entry) }) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = listOf(Archivist { entry -> println(entry) }) override val imprint = mapOf("service" to JsonPrimitive("checkout")) override val margins = timingMargin } ``` -Delivery is started with `CheckoutScribe.hire(...)` and stopped with -`CheckoutScribe.retire()`. Different objects can run concurrently without -sharing queues, savers, or lifecycle. +Intake starts open and the job starts dismissed. Delivery is started with +`CheckoutScribe.hire()`, paused with `CheckoutScribe.dismiss()`, and permanently +ended with `CheckoutScribe.retire()`. Different objects have independent private +buffers, archivists, and lifecycle controls. ## `Scroll` @@ -127,32 +134,24 @@ val margin = object : Margin { ## Delivery Configuration -Configure queue behavior through the `Channel` passed to an instance's -`hire(...)`. +Configure private-buffer behavior when creating the `Scribe`. ```kotlin -CheckoutScribe.hire( - channel = Channel( - capacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ), - onArchivist = { archivist, entry, error -> +object CheckoutScribe : Scribe() { + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure = { archivist: Archivist, entry: Entry, error: Throwable -> println("Archivist $archivist failed for $entry: $error") - }, -) -``` - -You can optionally provide a custom `CoroutineScope` to control the lifecycle of the delivery coroutine: - -```kotlin -val customScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + override val archivists = listOf(Archivist { entry -> println(entry) }) +} -CheckoutScribe.hire( - scope = customScope, - channel = Channel(capacity = 256), -) +CheckoutScribe.hire() ``` +The delivery coroutine is owned by the `Scribe` instance so it can remain alive while processing +is paused and resume on a later `hire()`. + ## Event Shapes The standard delivered event is a sealed `Scroll` snapshot. Fields written to @@ -170,21 +169,21 @@ mapOf( ```kotlin object ApplicationScribe : Scribe() { - override val shelves: List = listOf(Archivist { entry -> println(entry) }) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure = { archivist: Archivist, entry: Entry, error: Throwable -> + println("Archivist $archivist failed for $entry: ${error.message}") + } + override val archivists: List = listOf(Archivist { entry -> println(entry) }) override val onIgnition: ((Throwable) -> Unit)? = { throwable -> println("Uncaught exception: ${throwable.message}") } } -ApplicationScribe.hire( - channel = Channel(capacity = 256), - onArchivist = { archivist, entry, error -> - println("Archivist $archivist failed for $entry: ${error.message}") - }, -) +ApplicationScribe.hire() ``` -`onIgnition` is read when that runtime is first hired, but handles uncaught +`onIgnition` is read when processing is first hired, but handles uncaught exceptions at the platform level. Multiple runtimes should not independently claim this application-global hook. Archivist failures are reported by the -`onArchivist` callback passed to `hire(...)`. +`onArchiveFailure` property defined by the implementation. diff --git a/docs/console-showcase.md b/docs/console-showcase.md index 056c2e9..effe1fb 100644 --- a/docs/console-showcase.md +++ b/docs/console-showcase.md @@ -16,7 +16,7 @@ application console. There is no external service to configure. - `Archivist` fanned out across multiple outputs - Channel overflow behavior through `DROP_OLDEST` - Archivist error callbacks -- `retire()` and runtime re-hire +- reversible job pause through `dismiss()` and `hire()` - Safe `onIgnition` wiring ## Run It @@ -38,6 +38,6 @@ records are available through their platform run consoles. 6. Run `Archivist mixed flow` to print two scroll shapes through one archivist. 7. Run `Overflow demo` and observe that a burst can be trimmed under pressure. 8. Run `Archivist failure demo` and observe the printed archivist error while delivery continues. -9. Compare `retire() (light queue)` with `retire() with backlog`. +9. Compare `dismiss() (light queue)` with `dismiss() with backlog`. The in-app timeline mirrors delivered console records for convenient inspection. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9390e01..d96d11d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,22 +16,19 @@ kotlin { ## Create a Minimal `Scribe` -Create an object that extends `Scribe`, override its savers, then hire that -object's runtime with a `Channel`. +Create an object that extends `Scribe`, configure its private buffer, then hire its processor. ```kotlin object AppScribe : Scribe() { - override val shelves: List = listOf(Archivist { scroll -> + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = listOf(Archivist { scroll -> println(scroll) }) } -AppScribe.hire( - channel = Channel( - capacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ), -) +AppScribe.hire() ``` ## Emit a Single Event @@ -93,18 +90,24 @@ application may supply a configured object to a component. ```kotlin object PaymentsScribe : Scribe() { - override val shelves: List = listOf(Archivist { sendPaymentsRecord(it) }) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = listOf(Archivist { sendPaymentsRecord(it) }) } object AnalyticsScribe : Scribe() { - override val shelves: List = listOf(Archivist { sendAnalyticsRecord(it) }) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = listOf(Archivist { sendAnalyticsRecord(it) }) } -PaymentsScribe.hire(channel = Channel(256)) -AnalyticsScribe.hire(channel = Channel(256)) +PaymentsScribe.hire() +AnalyticsScribe.hire() ``` -Retiring `PaymentsScribe` does not stop `AnalyticsScribe`. +Dismissing `PaymentsScribe` pauses only its job and does not stop `AnalyticsScribe`. The emitted event shape is the scroll map itself: @@ -135,4 +138,4 @@ val scrollArchivist = Archivist { scroll -> println(scroll) } ## What to Read Next - [API Concepts](api-concepts.md) for the core types and terminology -- [Lifecycle and Delivery](lifecycle-and-delivery.md) for channel behavior, margins, shutdown, and archivist error callbacks +- [Lifecycle and Delivery](lifecycle-and-delivery.md) for intake, processing, retirement, and archivist error callbacks diff --git a/docs/index.md b/docs/index.md index b9394de..79f6cc0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,5 +36,5 @@ That pushes logging toward "what happened to this request or workflow?" instead - [Getting Started](getting-started.md) for setup and first usage - [API Concepts](api-concepts.md) for terminology and data model -- [Lifecycle and Delivery](lifecycle-and-delivery.md) for buffering, margins, and shutdown +- [Lifecycle and Delivery](lifecycle-and-delivery.md) for buffering, intake, processing, and retirement - [Console Showcase](console-showcase.md) for the runnable demo app and printed event records diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index fd93155..317b66f 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -1,123 +1,78 @@ # Lifecycle and Delivery -## Delivery Pipeline +## Private Buffer -A `Scribe` object delivers `Entry` snapshots through the `Channel` provided to -`hire(...)`. The channel is disposable and transfers ownership to that object, -which closes it on processor completion or `retire()`. Different `Scribe` -objects may be hired concurrently with independent channels. - -You can optionally provide a custom `CoroutineScope` to control the delivery coroutine lifecycle: - -```kotlin -val customScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - -CheckoutScribe.hire( - scope = customScope, - channel = Channel(capacity = 256), -) -``` +Each `Scribe` owns its delivery buffer. Callers configure its capacity and overflow policy, +but never own or close the underlying `Channel`: ```kotlin object CheckoutScribe : Scribe() { - override val shelves: List = listOf(Archivist { entry -> - println(entry) - }) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists = listOf(Archivist { entry -> println(entry) }) } - -CheckoutScribe.hire( - channel = Channel( - capacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ), - onArchivist = { archivist, entry, error -> - println("Archivist $archivist failed for $entry: ${error.message}") - }, -) ``` -## Emission APIs +Intake starts open and the job starts dismissed. Entries sealed before `hire()` remain in the +buffer according to the configured overflow policy. -Current emission calls are non-suspending and always produce scroll events: +## Independent Controls -- `seal(scribe, ...)` applies that runtime's footer margin, snapshots the - current `Scroll` data, and sends the resulting `Entry` +Intake and the processing job are independent: -Calls attempt an immediate channel send and block the calling thread if a -channel configured with `BufferOverflow.SUSPEND` is full. `Archivist.write(...)` -and `retire()` are the suspending parts of the API. There are no separate -best-effort emission APIs in this runtime shape. - -Multiple calls to `seal(...)` on the same `Scroll` are intentional. Each call -emits a separate `Entry` through the `Scribe` passed to that call. - -## Shared Context with `imprint` - -`imprint` adds fields to every new `Scroll` created by the same `Scribe` object. +| Intake | Job | Behavior | +|---|---|---| +| open | dismissed | new entries accumulate in the buffer | +| open | hired | new and buffered entries are delivered | +| closed | hired | no new entries are accepted; the backlog keeps draining | +| closed | dismissed | no intake or delivery occurs; the backlog is preserved | ```kotlin -object CheckoutScribe : Scribe() { - override val shelves: List = listOf(Archivist { println(it) }) - override val imprint = mapOf( - "app" to JsonPrimitive("checkout"), - "region" to JsonPrimitive("us-east-1"), - ) -} - -CheckoutScribe.hire(channel = Channel(capacity = 256)) +CheckoutScribe.hire() // start or resume the job +CheckoutScribe.dismiss() // request a cooperative pause immediately +CheckoutScribe.closeIntake() +CheckoutScribe.openIntake() ``` -These values are inserted into the scroll map and then appear in the delivered `Entry`. +Archivist failure handling is configured by the implementation's `onArchiveFailure` property. +Calling `hire()` +while processing is active has no effect. `dismiss()` is reversible, returns immediately, and does +not close intake. An entry already received by the worker may finish or remain held at the pause +gate; all other entries stay in the private buffer until the next `hire()`. -## Open and Close Hooks with `Margin` +Archivists process each entry concurrently. The processor waits for every archivist to finish +before consuming the next entry, preserving entry order for each archivist while preventing one +archivist from delaying the start of its peers. -Use `Margin` when scrolls need standard fields at creation and sealing time. +## Emission -```kotlin -val timingMargin = object : Margin { - override fun header(scroll: Scroll) { - scroll["started_at"] = JsonPrimitive(1000) - } - - override fun footer(scroll: Scroll) { - scroll["sealed_at"] = JsonPrimitive(2000) - } -} - -object CheckoutScribe : Scribe() { - override val shelves: List = listOf(Archivist { println(it) }) - override val margins = timingMargin -} +`seal(scribe)` applies the footer margin, snapshots the current `Scroll`, and attempts to place the +resulting `Entry` in the private buffer. It is non-suspending and never blocks waiting for buffer +space. Entries rejected because intake is closed, the buffer is full with `SUSPEND`, or retirement +has begun are not delivered. Prefer `DROP_OLDEST` or `DROP_LATEST` for synchronous logging. -CheckoutScribe.hire(channel = Channel(capacity = 256)) -``` +Multiple calls to `seal(...)` on the same `Scroll` intentionally create separate snapshots. -## Graceful Shutdown +## Terminal Retirement -Use `retire()` to stop intake and wait until queued delivery work is finished. +`retire()` is distinct from the reversible `dismiss()`: ```kotlin CheckoutScribe.retire() ``` -After `retire()`, that object's previous channel is closed and cannot be -reused. Call `hire(...)` with a new channel to restart its delivery. Other -active `Scribe` objects are unaffected. +It closes intake and the private buffer, finishes the active archivist call, drains all accepted +entries, and releases the internally owned scope. Intake and processing cannot restart +afterward. -## Uncaught Exceptions - -Override `onIgnition` on an application-owned `Scribe` object to install the -platform uncaught exception hook when that object is first hired: +The JVM SLF4J provider registers a shutdown hook that calls `retire()` automatically. It does not +call `hire()`: the application chooses when processing begins, while earlier SLF4J calls accumulate +in the backend's private buffer. -```kotlin -object ApplicationScribe : Scribe() { - override val shelves: List = listOf(Archivist { println(it) }) - override val onIgnition: ((Throwable) -> Unit)? = { throwable -> - println("Uncaught exception: ${throwable.message}") - } -} -``` +## Uncaught Exceptions -This hook is platform-global even though the property is declared by one -runtime object. Archivist-level failures are handled separately by `onArchivist` passed -to `hire(...)`. +Override `onIgnition` on an application-owned `Scribe` to install the platform uncaught exception +hook when processing is first hired. The hook is platform-global even though it is configured on +one instance. Archivist failures are handled separately by the implementation's +`onArchiveFailure` property. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 00c7a90..f3fba36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,8 @@ vanniktechMavenPublish = "0.37.0" kotlinxSerialization = "1.11.0" kotlinxCoroutines = "1.11.0" androidx-activity-compose = "1.13.0" +slf4j = "2.0.18" +classgraph = "4.8.181" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -18,6 +20,8 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } scribe = { module = "com.rafambn:scribe", version = "0.5.0" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +classgraph = { module = "io.github.classgraph:classgraph", version.ref = "classgraph" } [plugins] android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } diff --git a/scribe-slf4j/build.gradle.kts b/scribe-slf4j/build.gradle.kts new file mode 100644 index 0000000..ef85355 --- /dev/null +++ b/scribe-slf4j/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + kotlin("jvm") + alias(libs.plugins.vanniktech.mavenPublish) +} + +group = "com.rafambn" +version = "0.5.0" + +kotlin { + jvmToolchain(11) +} + +dependencies { + api(project(":scribe")) + api(libs.slf4j.api) + implementation(libs.classgraph) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.kotlin.test) +} + +mavenPublishing { + coordinates( + groupId = group.toString(), + artifactId = "scribe-slf4j", + version = version.toString() + ) + + pom { + name.set("Scribe SLF4J") + description.set("SLF4J binding for Scribe structured logging.") + url.set("https://scribe.rafambn.com") + licenses { + license { + name.set("Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + } + } + developers { + developer { + id.set("rafambn") + name.set("Rafael Mendonca") + email.set("rafambn@gmail.com") + url.set("https://rafambn.com") + } + } + scm { + url.set("https://github.com/rafambn/Scribe") + } + } + + publishToMavenCentral(automaticRelease = false) + signAllPublications() +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt new file mode 100644 index 0000000..ed62108 --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt @@ -0,0 +1,54 @@ +package com.rafambn.scribe.slf4j + +import org.slf4j.Marker +import org.slf4j.MDC +import org.slf4j.event.Level +import org.slf4j.helpers.AbstractLogger + +/** Lightweight SLF4J adapter that preserves the name requested from LoggerFactory. */ +internal class NamedScribeLogger( + private val loggerName: String, + private val scribe: Slf4jScribe, +) : AbstractLogger() { + override fun getName(): String = loggerName + + override fun isTraceEnabled(): Boolean = isEnabled(Level.TRACE, null) + override fun isTraceEnabled(marker: Marker?): Boolean = isEnabled(Level.TRACE, marker) + + override fun isDebugEnabled(): Boolean = isEnabled(Level.DEBUG, null) + override fun isDebugEnabled(marker: Marker?): Boolean = isEnabled(Level.DEBUG, marker) + + override fun isInfoEnabled(): Boolean = isEnabled(Level.INFO, null) + override fun isInfoEnabled(marker: Marker?): Boolean = isEnabled(Level.INFO, marker) + + override fun isWarnEnabled(): Boolean = isEnabled(Level.WARN, null) + override fun isWarnEnabled(marker: Marker?): Boolean = isEnabled(Level.WARN, marker) + + override fun isErrorEnabled(): Boolean = isEnabled(Level.ERROR, null) + override fun isErrorEnabled(marker: Marker?): Boolean = isEnabled(Level.ERROR, marker) + + private fun isEnabled(level: Level, marker: Marker?): Boolean = + scribe.isLoggingEnabled(loggerName, level, marker) + + override fun handleNormalizedLoggingCall( + level: Level, + marker: Marker?, + messagePattern: String?, + arguments: Array?, + throwable: Throwable?, + ) { + scribe.dispatch( + ScribeLoggingCall( + loggerName = loggerName, + level = level, + marker = marker, + messagePattern = messagePattern, + arguments = arguments, + throwable = throwable, + mdc = MDC.getCopyOfContextMap()?.toMap().orEmpty(), + ), + ) + } + + override fun getFullyQualifiedCallerName(): String = javaClass.name +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeBackend.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeBackend.kt new file mode 100644 index 0000000..fa204a4 --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeBackend.kt @@ -0,0 +1,6 @@ +package com.rafambn.scribe.slf4j + +/** Selects the single [Slf4jScribe] object that will back SLF4J in this application. */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ScribeBackend diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggerFactory.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggerFactory.kt new file mode 100644 index 0000000..a42b615 --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggerFactory.kt @@ -0,0 +1,13 @@ +package com.rafambn.scribe.slf4j + +import org.slf4j.ILoggerFactory +import org.slf4j.Logger +import java.util.concurrent.ConcurrentHashMap + +internal class ScribeLoggerFactory(private val scribe: Slf4jScribe) : ILoggerFactory { + private val loggers = ConcurrentHashMap() + + override fun getLogger(name: String): Logger { + return loggers.computeIfAbsent(name) { NamedScribeLogger(it, scribe) } + } +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggingCall.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggingCall.kt new file mode 100644 index 0000000..6fda19e --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggingCall.kt @@ -0,0 +1,43 @@ +package com.rafambn.scribe.slf4j + +import org.slf4j.Marker +import org.slf4j.event.Level + +/** Complete snapshot of a normalized SLF4J call routed to a [Slf4jScribe]. */ +data class ScribeLoggingCall( + val loggerName: String, + val level: Level, + val marker: Marker?, + val messagePattern: String?, + val arguments: Array?, + val throwable: Throwable?, + val mdc: Map, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ScribeLoggingCall + + if (loggerName != other.loggerName) return false + if (level != other.level) return false + if (marker != other.marker) return false + if (messagePattern != other.messagePattern) return false + if (!arguments.contentEquals(other.arguments)) return false + if (throwable != other.throwable) return false + if (mdc != other.mdc) return false + + return true + } + + override fun hashCode(): Int { + var result = loggerName.hashCode() + result = 31 * result + level.hashCode() + result = 31 * result + (marker?.hashCode() ?: 0) + result = 31 * result + (messagePattern?.hashCode() ?: 0) + result = 31 * result + (arguments?.contentHashCode() ?: 0) + result = 31 * result + (throwable?.hashCode() ?: 0) + result = 31 * result + mdc.hashCode() + return result + } +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt new file mode 100644 index 0000000..cb852d8 --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt @@ -0,0 +1,85 @@ +package com.rafambn.scribe.slf4j + +import io.github.classgraph.ClassGraph +import kotlinx.coroutines.runBlocking +import org.slf4j.ILoggerFactory +import org.slf4j.IMarkerFactory +import org.slf4j.helpers.BasicMarkerFactory +import org.slf4j.helpers.BasicMDCAdapter +import org.slf4j.spi.MDCAdapter +import org.slf4j.spi.SLF4JServiceProvider + +/** SLF4J provider backed by the single [Slf4jScribe] object annotated with [ScribeBackend]. */ +class ScribeServiceProvider : SLF4JServiceProvider { + private val markerFactory: IMarkerFactory = BasicMarkerFactory() + private val mdcAdapter: MDCAdapter = BasicMDCAdapter() + private lateinit var scribe: Slf4jScribe + private lateinit var loggerFactory: ILoggerFactory + + override fun initialize() { + check(!::scribe.isInitialized) { "Scribe SLF4J provider is already initialized." } + val createdScribe = discoverScribe() + try { + registerShutdownHook(createdScribe) + } catch (error: Throwable) { + runCatching { runBlocking { createdScribe.retire() } } + .exceptionOrNull() + ?.let(error::addSuppressed) + throw error + } + scribe = createdScribe + loggerFactory = ScribeLoggerFactory(createdScribe) + } + + override fun getLoggerFactory(): ILoggerFactory { + check(::loggerFactory.isInitialized) { "Scribe SLF4J provider has not been initialized." } + return loggerFactory + } + + override fun getMarkerFactory(): IMarkerFactory = markerFactory + + override fun getMDCAdapter(): MDCAdapter = mdcAdapter + + override fun getRequestedApiVersion(): String = "2.0.99" + + private fun discoverScribe(): Slf4jScribe { + val backendClasses = ClassGraph() + .enableAnnotationInfo() + .scan() + .use { scan -> + scan.getClassesWithAnnotation(ScribeBackend::class.java.name) + .map { it.loadClass() } + } + + check(backendClasses.isNotEmpty()) { + "No Scribe SLF4J backend was found. Annotate exactly one Kotlin object extending " + + "Slf4jScribe with @ScribeBackend." + } + check(backendClasses.size == 1) { + "Multiple Scribe SLF4J backends were found: ${backendClasses.joinToString { it.name }}. " + + "Annotate exactly one Kotlin object with @ScribeBackend." + } + + val backendClass = backendClasses.single() + check(Slf4jScribe::class.java.isAssignableFrom(backendClass)) { + "@ScribeBackend class ${backendClass.name} must extend Slf4jScribe." + } + + val instance = runCatching { backendClass.getField("INSTANCE").get(null) } + .getOrElse { error -> + throw IllegalStateException( + "@ScribeBackend class ${backendClass.name} must be a Kotlin object.", + error, + ) + } + return instance as Slf4jScribe + } + + private fun registerShutdownHook(scribe: Slf4jScribe) { + val hook = Thread( + { runBlocking { scribe.retire() } }, + "scribe-slf4j-shutdown", + ) + Runtime.getRuntime().addShutdownHook(hook) + } +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/Slf4jScribe.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/Slf4jScribe.kt new file mode 100644 index 0000000..888f76d --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/Slf4jScribe.kt @@ -0,0 +1,54 @@ +package com.rafambn.scribe.slf4j + +import com.rafambn.scribe.Scribe +import com.rafambn.scribe.seal +import kotlinx.serialization.json.JsonPrimitive +import org.slf4j.Marker +import org.slf4j.event.Level +import org.slf4j.helpers.MessageFormatter + +/** Process-wide Scribe backend selected at runtime with [ScribeBackend]. */ +abstract class Slf4jScribe : Scribe() { + + /** Returns whether a call should be accepted before a scroll is allocated. */ + open fun isEnabled(loggerName: String, level: Level, marker: Marker?): Boolean = true + + /** Converts a normalized SLF4J call into a scroll owned by this Scribe. */ + open fun handleNormalizedLoggingCall(call: ScribeLoggingCall) { + val message = MessageFormatter.arrayFormat(call.messagePattern, call.arguments).message + val scroll = newScroll() + + scroll["level"] = JsonPrimitive(call.level.name) + scroll["logger"] = JsonPrimitive(call.loggerName) + scroll["message"] = JsonPrimitive(message) + + call.marker?.let { + scroll["marker"] = JsonPrimitive(it.name) + } + + call.throwable?.let { + scroll["exception"] = JsonPrimitive(it.stackTraceToString()) + } + + call.mdc.forEach { (key, value) -> + if (key !in RESERVED_FIELDS && !scroll.containsKey(key)) { + scroll[key] = JsonPrimitive(value) + } + } + + scroll.seal(this) + } + + internal fun isLoggingEnabled(loggerName: String, level: Level, marker: Marker?): Boolean = + isIntakeOpen && isEnabled(loggerName, level, marker) + + internal fun dispatch(call: ScribeLoggingCall) { + if (isIntakeOpen) { + handleNormalizedLoggingCall(call) + } + } + + private companion object { + val RESERVED_FIELDS = setOf("scroll_id", "level", "logger", "message", "marker", "exception") + } +} diff --git a/scribe-slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider b/scribe-slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider new file mode 100644 index 0000000..9a18c5c --- /dev/null +++ b/scribe-slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider @@ -0,0 +1 @@ +com.rafambn.scribe.slf4j.ScribeServiceProvider diff --git a/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt b/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt new file mode 100644 index 0000000..85bfa09 --- /dev/null +++ b/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt @@ -0,0 +1,175 @@ +package com.rafambn.scribe.slf4j + +import com.rafambn.scribe.Archivist +import com.rafambn.scribe.Entry +import com.rafambn.scribe.seal +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonPrimitive +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.slf4j.Marker +import org.slf4j.MarkerFactory +import org.slf4j.event.Level +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@ScribeBackend +internal object TestScribeBackend : Slf4jScribe() { + val captured = CopyOnWriteArrayList() + + override val archivists = listOf(Archivist { entry -> captured.add(entry) }) + override val bufferCapacity: Int = 256 + override val bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + + override fun isEnabled(loggerName: String, level: Level, marker: Marker?): Boolean { + return if (loggerName == "CustomLogger") { + level == Level.ERROR && marker?.name == "CUSTOM" + } else { + true + } + } + + override fun handleNormalizedLoggingCall(call: ScribeLoggingCall) { + if (call.loggerName != "CustomLogger") { + super.handleNormalizedLoggingCall(call) + return + } + + val scroll = newScroll() + scroll["source"] = JsonPrimitive(call.loggerName) + scroll["severity"] = JsonPrimitive(call.level.name) + scroll["template"] = JsonPrimitive(call.messagePattern.orEmpty()) + scroll["argument"] = JsonPrimitive(call.arguments?.firstOrNull().toString()) + scroll["marker"] = JsonPrimitive(call.marker?.name.orEmpty()) + scroll["failure"] = JsonPrimitive(call.throwable?.message.orEmpty()) + scroll["request"] = JsonPrimitive(call.mdc.getValue("requestId")) + scroll.seal(this) + } +} + +class ScribeSLF4JTest { + @BeforeTest + fun resetCapturedEntries() { + MDC.clear() + TestScribeBackend.captured.clear() + TestScribeBackend.hire() + } + + @Test + fun `logging buffers until the user hires processing`() = runBlocking { + val captured = CopyOnWriteArrayList() + val backend = object : Slf4jScribe() { + override val archivists = listOf(Archivist { entry -> captured.add(entry) }) + override val bufferCapacity: Int = 256 + override val bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + } + + backend.dispatch( + ScribeLoggingCall( + loggerName = "BufferedLogger", + level = Level.INFO, + marker = null, + messagePattern = "before hire", + arguments = null, + throwable = null, + mdc = emptyMap(), + ), + ) + delay(50) + assertTrue(captured.isEmpty()) + + backend.hire() + withTimeout(5.seconds) { + while (captured.isEmpty()) delay(10) + } + + assertEquals("before hire", (captured.single()["message"] as JsonPrimitive).content) + backend.retire() + } + + @Test + fun `provider discovers annotated backend and user starts processing`() = runBlocking { + val logger = LoggerFactory.getLogger("TestLogger") + + assertTrue(logger.isInfoEnabled) + logger.info("Hello SLF4J!") + awaitCapturedEntries(1) + + val entry = TestScribeBackend.captured.single() + assertEquals("INFO", (entry["level"] as JsonPrimitive).content) + assertEquals("TestLogger", (entry["logger"] as JsonPrimitive).content) + assertEquals("Hello SLF4J!", (entry["message"] as JsonPrimitive).content) + } + + @Test + fun `mdc context is included without replacing reserved log fields`() = runBlocking { + val logger = LoggerFactory.getLogger("MdcLogger") + + try { + MDC.put("requestId", "req-123") + MDC.put("message", "must not replace the log message") + MDC.put("scroll_id", "must not replace the scroll id") + logger.info("Processing request") + + MDC.clear() + logger.info("Context cleared") + awaitCapturedEntries(2) + } finally { + MDC.clear() + } + + assertEquals("req-123", (TestScribeBackend.captured[0]["requestId"] as JsonPrimitive).content) + assertEquals("Processing request", (TestScribeBackend.captured[0]["message"] as JsonPrimitive).content) + assertFalse( + (TestScribeBackend.captured[0]["scroll_id"] as JsonPrimitive).content.startsWith("must not"), + ) + assertTrue("requestId" !in TestScribeBackend.captured[1]) + } + + @Test + fun `annotated scribe controls levels and normalized call mapping`() = runBlocking { + val logger = LoggerFactory.getLogger("CustomLogger") + val marker = MarkerFactory.getMarker("CUSTOM") + + try { + assertFalse(logger.isDebugEnabled) + assertFalse(logger.isErrorEnabled) + assertTrue(logger.isErrorEnabled(marker)) + logger.info("Ignored") + logger.error("Also ignored") + + MDC.put("requestId", "req-custom") + logger.error(marker, "Failure {}", 42, IllegalStateException("boom")) + awaitCapturedEntries(1) + } finally { + MDC.clear() + } + + val entry = TestScribeBackend.captured.single() + assertEquals("CustomLogger", (entry["source"] as JsonPrimitive).content) + assertEquals("ERROR", (entry["severity"] as JsonPrimitive).content) + assertEquals("Failure {}", (entry["template"] as JsonPrimitive).content) + assertEquals("42", (entry["argument"] as JsonPrimitive).content) + assertEquals("CUSTOM", (entry["marker"] as JsonPrimitive).content) + assertEquals("boom", (entry["failure"] as JsonPrimitive).content) + assertEquals("req-custom", (entry["request"] as JsonPrimitive).content) + } + + private suspend fun awaitCapturedEntries(count: Int) { + withTimeout(5.seconds) { + while (TestScribeBackend.captured.size < count) { + delay(10) + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d6628b7..c1e367b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,7 @@ rootProject.name = "Scribe" include(":scribe") +include(":scribe-slf4j") include(":testApp:shared") include(":testApp:androidApp") include(":testApp:jvmApp") diff --git a/testApp/README.md b/testApp/README.md index e130675..ff7f216 100644 --- a/testApp/README.md +++ b/testApp/README.md @@ -15,8 +15,8 @@ No server or local observability stack is required. - `Margin.header(...)` and `Margin.footer(...)` - `Archivist` fanned out across multiple outputs - Channel overflow behavior through `DROP_OLDEST` -- Archivist failure reporting through `hire(onArchivist = ...)` -- `retire()` and runtime re-hire +- Archivist failure reporting through the implementation's `onArchiveFailure` property +- reversible job pause through `dismiss()` and `hire()` - `onIgnition` wiring without intentionally crashing the app ## Run The App @@ -29,7 +29,7 @@ From the repository root: ``` The UI contains demo actions for quick scrolls, wide events, JSON serialization, queue -delivery, archivist failures, and runtime shutdown. Each delivered `Entry` is +delivery, archivist failures, and runtime retirement. Each delivered `Entry` is rendered as JSON and printed to stdout, while the most recent records remain visible in the in-app timeline. diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt index c5d486b..98a2a9f 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt @@ -6,6 +6,7 @@ import com.rafambn.scribe.Scribe import com.rafambn.scribe.Scroll import com.rafambn.scribe.Entry import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonPrimitive @@ -17,7 +18,13 @@ import scribe.demo.data.sampleImprint import scribe.demo.platformName import kotlin.time.Duration.Companion.milliseconds -class AppScribe(onRecord: (Entry) -> Unit) : Scribe() { +class AppScribe( + onRecord: (Entry) -> Unit, + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)?, +) : Scribe() { + + override val bufferCapacity: Int = 2 + override val bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST var overflowDelay: Boolean = false diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt index 6dc238c..12a9cb2 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt @@ -129,12 +129,12 @@ fun HomeContent( enabled = !isBusy, ) ActionGroup( - title = "Shutdown And Safety", - description = "Use retire() shutdown flows and wire the onIgnition callback safely.", + title = "Lifecycle And Safety", + description = "Pause and resume processing, then wire the onIgnition callback safely.", buttons = listOf( "Re-hire Scribe" to onRehireMainScribe, - "retire() (light queue)" to onRunRetireScenario, - "retire() with backlog" to onRunPlanRetireScenario, + "dismiss() (light queue)" to onRunRetireScenario, + "dismiss() preserving backlog" to onRunPlanRetireScenario, "Wire onIgnition" to onWireIgnitionScenario, ), enabled = !isBusy, diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt index 23aee40..b640ae4 100644 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt +++ b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt @@ -14,8 +14,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json @@ -52,18 +50,17 @@ class HomeViewModel { private val _state = MutableStateFlow(HomeState()) val state: StateFlow = _state.asStateFlow() - private val appScribe = AppScribe { entry -> handleRecord(entry) } + private val appScribe = AppScribe( + onRecord = { entry -> handleRecord(entry) }, + onArchiveFailure = { archivist, entry, error -> + appendArchivistError( + "Archivist failure in ${archivist::class.simpleName ?: "Archivist"} for ${entryKind()}: ${error.message ?: error}", + ) + }, + ) init { - appScribe.hire( - scope = scope, - channel = Channel(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST), - onArchiveFailure = { archivist, entry, error -> - appendArchivistError( - "Archivist failure in ${archivist::class.simpleName ?: "Archivist"} for ${entryKind()}: ${error.message ?: error}", - ) - }, - ) + appScribe.hire() } fun runQuickScrollScenario() = launchScenario("Quick scroll emission demo") { @@ -226,11 +223,11 @@ class HomeViewModel { val delivered = printedEvents - baseline appendTimeline( title = "Overflow result", - detail = "Attempted $attempted quick scrolls with channel capacity 2 and DROP_OLDEST; delivered $delivered.", + detail = "Attempted $attempted quick scrolls with private buffer capacity 2 and DROP_OLDEST; delivered $delivered.", payload = "", success = delivered < attempted, ) - updateStatus("Ran overflow demo with Channel(..., onBufferOverflow = DROP_OLDEST).") + updateStatus("Ran overflow demo with the private buffer configured as DROP_OLDEST.") } fun runArchivistFailureScenario() = launchScenario("Archivist error demo") { @@ -239,45 +236,45 @@ class HomeViewModel { message = "Intentional archivist failure probe", level = "WARN", ) - updateStatus("Archivist failure demo ran; onArchivist callback captures the injected failure.") + updateStatus("Archivist failure demo ran; onArchiveFailure captures the injected failure.") } - fun runRetireScenario() = launchScenario("retire() demo") { - emitQuickScroll("shutdown", "retire() with light queue", "INFO") + fun runRetireScenario() = launchScenario("dismiss() demo") { + emitQuickScroll("lifecycle", "dismiss() with light queue", "INFO") val started = currentEpochMillis() - appScribe.retire() + appScribe.dismiss() val elapsed = currentEpochMillis() - started activeScrolls.clear() _state.update { it.copy(isRetired = true) } refreshActiveScrolls() appendTimeline( - title = "retire()", - detail = "retire() finished in ${elapsed}ms and retired the shared demo Scribe instance.", + title = "dismiss()", + detail = "dismiss() finished in ${elapsed}ms and paused the job without closing intake.", payload = "", success = true, ) - updateStatus("The shared demo Scribe is retired. Press Re-hire Scribe before sending more messages.") + updateStatus("Processing is paused; new messages remain buffered until Re-hire Scribe is pressed.") } - fun runPlanRetireScenario() = launchScenario("retire() with backlog demo") { + fun runPlanRetireScenario() = launchScenario("dismiss() with backlog demo") { repeat(6) { index -> - emitQuickScroll("shutdown", "drain probe #$index", "INFO") + emitQuickScroll("lifecycle", "buffered probe #$index", "INFO") } val started = currentEpochMillis() - appScribe.retire() + appScribe.dismiss() val elapsed = currentEpochMillis() - started activeScrolls.clear() _state.update { it.copy(isRetired = true) } refreshActiveScrolls() appendTimeline( - title = "retire() with backlog", - detail = "retire() took ${elapsed}ms after a small queued backlog.", + title = "dismiss() with backlog", + detail = "dismiss() took ${elapsed}ms and preserved the queued backlog.", payload = "", success = true, ) - updateStatus("The shared demo Scribe is retired after draining queued work. Press Re-hire Scribe to continue.") + updateStatus("Processing is paused and queued work is preserved. Press Re-hire Scribe to drain it.") } fun wireIgnitionScenario() = launchScenario("onIgnition wiring") { @@ -306,15 +303,7 @@ class HomeViewModel { return@launchScenario } - appScribe.hire( - channel = Channel(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST), - scope = scope, - onArchiveFailure = { archivist, entry, error -> - appendArchivistError( - "Archivist failure in ${archivist::class.simpleName ?: "Archivist"} for ${entryKind()}: ${error.message ?: error}", - ) - }, - ) + appScribe.hire() _state.update { it.copy(isRetired = false) } refreshActiveScrolls() updateStatus("The shared demo Scribe was re-hired and can send messages again.") From 36c35caaa09ab87b1c41af218b31e22c725c708e Mon Sep 17 00:00:00 2001 From: rafambn Date: Mon, 10 Aug 2026 21:29:53 -0300 Subject: [PATCH 07/11] Remove `testApp` module and its associated files; Added `testServer`; --- docs/console-showcase.md | 43 -- docs/index.md | 1 - mkdocs.yml | 1 - .../kotlin/com/rafambn/scribe/Scribe.kt | 8 +- settings.gradle.kts | 4 +- testApp/README.md | 65 --- testApp/androidApp/build.gradle.kts | 27 -- .../androidApp/src/main/AndroidManifest.xml | 12 - .../kotlin/com/rafambn/scribe/MainActivity.kt | 15 - .../androidApp/src/main/proguard-rules.pro | 1 - testApp/iosApp/src/ViewController.kt | 4 - testApp/iosApp/src/iosApp.swift | 25 - testApp/jvmApp/build.gradle.kts | 20 - testApp/jvmApp/src/main/kotlin/main.kt | 9 - testApp/shared/build.gradle.kts | 28 -- .../kotlin/scribe/demo/WorldAndroid.kt | 5 - .../commonMain/kotlin/scribe/demo/Platform.kt | 5 - .../kotlin/scribe/demo/data/ConsoleRecord.kt | 54 --- .../kotlin/scribe/demo/data/DemoSchemas.kt | 48 -- .../kotlin/scribe/demo/data/TimelineItem.kt | 8 - .../kotlin/scribe/demo/scribe/AppScribe.kt | 71 --- .../kotlin/scribe/demo/ui/HomeContent.kt | 308 ------------- .../kotlin/scribe/demo/ui/HomeScreen.kt | 44 -- .../kotlin/scribe/demo/ui/HomeState.kt | 16 - .../kotlin/scribe/demo/ui/HomeViewModel.kt | 432 ------------------ .../iosMain/kotlin/scribe/demo/WorldIos.kt | 7 - .../jvmMain/kotlin/scribe/demo/WorldJvm.kt | 5 - testServer/build.gradle.kts | 18 + .../scribe/testserver/HealthHandler.kt | 34 ++ .../com/rafambn/scribe/testserver/Main.kt | 28 ++ .../scribe/testserver/TestServerScribe.kt | 22 + 31 files changed, 107 insertions(+), 1261 deletions(-) delete mode 100644 docs/console-showcase.md delete mode 100644 testApp/README.md delete mode 100644 testApp/androidApp/build.gradle.kts delete mode 100644 testApp/androidApp/src/main/AndroidManifest.xml delete mode 100644 testApp/androidApp/src/main/kotlin/com/rafambn/scribe/MainActivity.kt delete mode 100644 testApp/androidApp/src/main/proguard-rules.pro delete mode 100644 testApp/iosApp/src/ViewController.kt delete mode 100644 testApp/iosApp/src/iosApp.swift delete mode 100644 testApp/jvmApp/build.gradle.kts delete mode 100644 testApp/jvmApp/src/main/kotlin/main.kt delete mode 100644 testApp/shared/build.gradle.kts delete mode 100644 testApp/shared/src/androidMain/kotlin/scribe/demo/WorldAndroid.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/Platform.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/data/DemoSchemas.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/data/TimelineItem.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt delete mode 100644 testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt delete mode 100644 testApp/shared/src/iosMain/kotlin/scribe/demo/WorldIos.kt delete mode 100644 testApp/shared/src/jvmMain/kotlin/scribe/demo/WorldJvm.kt create mode 100644 testServer/build.gradle.kts create mode 100644 testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt create mode 100644 testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt create mode 100644 testServer/src/main/kotlin/com/rafambn/scribe/testserver/TestServerScribe.kt diff --git a/docs/console-showcase.md b/docs/console-showcase.md deleted file mode 100644 index effe1fb..0000000 --- a/docs/console-showcase.md +++ /dev/null @@ -1,43 +0,0 @@ -# Console Showcase - -The `testApp` module is the runnable Scribe demo. It uses an application-owned -`Scribe` object and prints each delivered record as formatted JSON to the -application console. There is no external service to configure. - -## What The Showcase Demonstrates - -- Quick scrolls: immediately sealed one-shot events -- `newScroll(...)` with generated and custom IDs -- Direct `Scroll` map-like writes and explicit delivery runtime selection -- `extend(scroll)` and `append(key, scroll)` -- Map read/remove operations -- `seal(...)` snapshots and fail-styled scrolls (via data fields) -- `Margin` -- `Archivist` fanned out across multiple outputs -- Channel overflow behavior through `DROP_OLDEST` -- Archivist error callbacks -- reversible job pause through `dismiss()` and `hire()` -- Safe `onIgnition` wiring - -## Run It - -```bash -./gradlew :testApp:jvmApp:run -``` - -The desktop app writes records to the launching terminal. Android and iOS -records are available through their platform run consoles. - -## Suggested Experiments - -1. Run `Checkout flow` and inspect a wide-event JSON record. -2. Run `Map read/remove` to observe mutation before sealing. -3. Run `Margins + seal(failure)` and verify timing fields plus the `failure_reason`/`success=false` data markers. -4. Run `JSON object serialization` to inspect a nested payload. -5. Run `String template message` to inspect the `message` and `order_id` fields. -6. Run `Archivist mixed flow` to print two scroll shapes through one archivist. -7. Run `Overflow demo` and observe that a burst can be trimmed under pressure. -8. Run `Archivist failure demo` and observe the printed archivist error while delivery continues. -9. Compare `dismiss() (light queue)` with `dismiss() with backlog`. - -The in-app timeline mirrors delivered console records for convenient inspection. diff --git a/docs/index.md b/docs/index.md index 79f6cc0..8deb1b3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,4 +37,3 @@ That pushes logging toward "what happened to this request or workflow?" instead - [Getting Started](getting-started.md) for setup and first usage - [API Concepts](api-concepts.md) for terminology and data model - [Lifecycle and Delivery](lifecycle-and-delivery.md) for buffering, intake, processing, and retirement -- [Console Showcase](console-showcase.md) for the runnable demo app and printed event records diff --git a/mkdocs.yml b/mkdocs.yml index 9015af9..fab8371 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,7 +28,6 @@ nav: - Getting Started: getting-started.md - API Concepts: api-concepts.md - Lifecycle and Delivery: lifecycle-and-delivery.md - - Console Showcase: console-showcase.md markdown_extensions: - admonition diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index cb123c1..5c5461a 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -40,16 +40,16 @@ abstract class Scribe { /** * Archivists receiving structured logs emitted by this instance. */ - protected abstract val archivists: List + protected open val archivists: List = emptyList() /** Maximum number of entries retained by this instance's private buffer. */ - protected abstract val bufferCapacity: Int + protected open val bufferCapacity: Int = 256 /** Overflow behavior used when this instance's private buffer is full. */ - protected abstract val bufferOverflow: BufferOverflow + protected open val bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST /** Callback invoked when an archivist fails to write an entry. */ - protected abstract val onArchiveFailure: ((archivist: Archivist, entry: Entry, error: Throwable) -> Unit)? + protected open val onArchiveFailure: ((archivist: Archivist, entry: Entry, error: Throwable) -> Unit)? = null /** * Fields copied into every [Scroll] created by this instance. diff --git a/settings.gradle.kts b/settings.gradle.kts index c1e367b..0d6c783 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,9 +2,7 @@ rootProject.name = "Scribe" include(":scribe") include(":scribe-slf4j") -include(":testApp:shared") -include(":testApp:androidApp") -include(":testApp:jvmApp") +include(":testServer") pluginManagement { repositories { diff --git a/testApp/README.md b/testApp/README.md deleted file mode 100644 index ff7f216..0000000 --- a/testApp/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# testApp Scribe Showcase - -This app is a guided demo for the current Scribe API. It runs on JVM desktop, -Android, and iOS, and writes each delivered record to the application console. -No server or local observability stack is required. - -## What This Demo Covers - -- An application-owned object extending `Scribe` -- Quick scrolls: immediately sealed one-shot events -- `newScroll(...)` with generated and custom IDs -- Direct map-like writes on `Scroll` and explicit delivery runtime selection -- Map reads/removals before sealing -- `seal(...)` snapshots and fail-styled scrolls (via data fields) -- `Margin.header(...)` and `Margin.footer(...)` -- `Archivist` fanned out across multiple outputs -- Channel overflow behavior through `DROP_OLDEST` -- Archivist failure reporting through the implementation's `onArchiveFailure` property -- reversible job pause through `dismiss()` and `hire()` -- `onIgnition` wiring without intentionally crashing the app - -## Run The App - -From the repository root: - -```bash -./gradlew :testApp:jvmApp:run -./gradlew :testApp:androidApp:installDebug -``` - -The UI contains demo actions for quick scrolls, wide events, JSON serialization, queue -delivery, archivist failures, and runtime retirement. Each delivered `Entry` is -rendered as JSON and printed to stdout, while the most recent records remain -visible in the in-app timeline. - -Example console output: - -```json -{ - "event_kind": "scroll", - "demo_name": "checkout_scroll", - "scroll_id": "checkout-42", - "gateway": "stripe" -} -``` - -## Inspect Output - -For the JVM app, records appear in the terminal where `./gradlew :testApp:jvmApp:run` -was started. For Android, view application stdout in Logcat or the run console. -The iOS run console likewise displays the records. - -Useful fields include: - -- `event_kind` -- `demo_name` -- `platform` -- `app_version` -- `saver_type` -- `scroll_id` -- Scroll fields such as `tag`, `level`, `success`, `gateway`, `order_id`, `order_snapshot`, and `elapsed_ms` - -The overflow scenario intentionally slows the console archivist while using a small -dropping channel; fewer printed records than attempted quick scrolls demonstrates the -configured overflow behavior. diff --git a/testApp/androidApp/build.gradle.kts b/testApp/androidApp/build.gradle.kts deleted file mode 100644 index 4cb2346..0000000 --- a/testApp/androidApp/build.gradle.kts +++ /dev/null @@ -1,27 +0,0 @@ -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlinComposeCompiler) - alias(libs.plugins.compose.multiplatform) -} - -android { - namespace = "scribe.demo.android" - compileSdk = libs.versions.android.compileSdk.get().toInt() - - defaultConfig { - applicationId = "scribe.demo.android" - minSdk = libs.versions.android.minSdk.get().toInt() - versionCode = 1 - versionName = "1.0" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } -} - -dependencies { - implementation(project(":testApp:shared")) - implementation(libs.androidx.activity.compose) -} diff --git a/testApp/androidApp/src/main/AndroidManifest.xml b/testApp/androidApp/src/main/AndroidManifest.xml deleted file mode 100644 index 203d0d5..0000000 --- a/testApp/androidApp/src/main/AndroidManifest.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/testApp/androidApp/src/main/kotlin/com/rafambn/scribe/MainActivity.kt b/testApp/androidApp/src/main/kotlin/com/rafambn/scribe/MainActivity.kt deleted file mode 100644 index 83f3b7e..0000000 --- a/testApp/androidApp/src/main/kotlin/com/rafambn/scribe/MainActivity.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.rafambn.scribe - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import scribe.demo.ui.HomeScreen - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - HomeScreen() - } - } -} \ No newline at end of file diff --git a/testApp/androidApp/src/main/proguard-rules.pro b/testApp/androidApp/src/main/proguard-rules.pro deleted file mode 100644 index fb164d6..0000000 --- a/testApp/androidApp/src/main/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ -# Add project specific ProGuard rules here. diff --git a/testApp/iosApp/src/ViewController.kt b/testApp/iosApp/src/ViewController.kt deleted file mode 100644 index 125b6ed..0000000 --- a/testApp/iosApp/src/ViewController.kt +++ /dev/null @@ -1,4 +0,0 @@ -import androidx.compose.ui.window.ComposeUIViewController -import scribe.demo.ui.Screen - -fun ViewController() = ComposeUIViewController { Screen() } diff --git a/testApp/iosApp/src/iosApp.swift b/testApp/iosApp/src/iosApp.swift deleted file mode 100644 index 358047c..0000000 --- a/testApp/iosApp/src/iosApp.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI -import KotlinModules - -struct ComposeView: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> some UIViewController { - ViewControllerKt.ViewController() - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} -} - -struct ContentView: View { - var body: some View { - ComposeView().ignoresSafeArea(.all, edges: .bottom) - } -} - -@main -struct iosApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/testApp/jvmApp/build.gradle.kts b/testApp/jvmApp/build.gradle.kts deleted file mode 100644 index c8dafa6..0000000 --- a/testApp/jvmApp/build.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - kotlin("jvm") - alias(libs.plugins.kotlinComposeCompiler) - alias(libs.plugins.compose.multiplatform) -} - -kotlin { - jvmToolchain(11) -} - -dependencies { - implementation(project(":testApp:shared")) - implementation(compose.desktop.currentOs) -} - -compose.desktop { - application { - mainClass = "MainKt" - } -} diff --git a/testApp/jvmApp/src/main/kotlin/main.kt b/testApp/jvmApp/src/main/kotlin/main.kt deleted file mode 100644 index 6301cd0..0000000 --- a/testApp/jvmApp/src/main/kotlin/main.kt +++ /dev/null @@ -1,9 +0,0 @@ -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import scribe.demo.ui.HomeScreen - -fun main() = application { - Window(onCloseRequest = ::exitApplication) { - HomeScreen() - } -} diff --git a/testApp/shared/build.gradle.kts b/testApp/shared/build.gradle.kts deleted file mode 100644 index a65a98b..0000000 --- a/testApp/shared/build.gradle.kts +++ /dev/null @@ -1,28 +0,0 @@ -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.kotlinSerialization) - alias(libs.plugins.android.kotlin.multiplatform.library) - alias(libs.plugins.kotlinComposeCompiler) - alias(libs.plugins.compose.multiplatform) -} - -kotlin { - jvm() - android { - namespace = "scribe.demo.shared" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() - } - iosArm64() - iosSimulatorArm64() - - sourceSets { - commonMain.dependencies { - api("org.jetbrains.compose.foundation:foundation:1.11.1") - implementation(libs.material3) - implementation(project(":scribe")) - implementation(libs.kotlinx.serialization.json) - implementation(libs.kotlinx.coroutines.core) - } - } -} diff --git a/testApp/shared/src/androidMain/kotlin/scribe/demo/WorldAndroid.kt b/testApp/shared/src/androidMain/kotlin/scribe/demo/WorldAndroid.kt deleted file mode 100644 index e51e159..0000000 --- a/testApp/shared/src/androidMain/kotlin/scribe/demo/WorldAndroid.kt +++ /dev/null @@ -1,5 +0,0 @@ -package scribe.demo - -actual fun platformName() = "Android" - -actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/Platform.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/Platform.kt deleted file mode 100644 index ad0d0f7..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/Platform.kt +++ /dev/null @@ -1,5 +0,0 @@ -package scribe.demo - -expect fun platformName(): String - -expect fun currentEpochMillis(): Long diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt deleted file mode 100644 index 0743b3c..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt +++ /dev/null @@ -1,54 +0,0 @@ -package scribe.demo.data - -import com.rafambn.scribe.Entry -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonPrimitive - -typealias ConsoleRecord = Map - -fun consoleRecordFromEntry( - entry: Entry, - demoName: String, - platform: String, - archivistType: String, - appVersion: String, - recordedAt: Long, -): ConsoleRecord { - val payload = linkedMapOf() - payload["_timestamp"] = JsonPrimitive(recordedAt) - payload["event_kind"] = JsonPrimitive("scroll") - payload["demo_name"] = JsonPrimitive(stringField(entry, "demo_name") ?: demoName) - payload["platform"] = JsonPrimitive(platform) - payload["app_version"] = JsonPrimitive(appVersion) - payload["archivist_type"] = JsonPrimitive(archivistType) - payload["scroll_id"] = JsonPrimitive(stringField(entry, "scroll_id") ?: "missing-scroll-id") - stringField(entry, "message")?.let { payload["message"] = JsonPrimitive(it) } - entry["order_id"]?.let { payload["order_id"] = it } - ?: entry["ordemId"]?.let { payload["order_id"] = it } - entry.forEach { (key, value) -> - if (key !in payload) { - payload[key] = value - } - } - return payload -} - -fun recordSummary(record: ConsoleRecord): String = - record.scroll_id ?: "scroll" - -fun payloadEventKind(record: ConsoleRecord): String = - record["event_kind"]?.jsonPrimitive?.contentOrNull ?: "unknown" - -private fun stringField(data: Map, key: String): String? = - data[key]?.jsonPrimitive?.contentOrNull - -val ConsoleRecord.scroll_id: String? - get() = this["scroll_id"]?.jsonPrimitive?.contentOrNull - -fun sampleImprint(platform: String): Map = mapOf( - "service" to JsonPrimitive("scribe-showcase"), - "environment" to JsonPrimitive("local"), - "platform" to JsonPrimitive(platform), -) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/DemoSchemas.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/DemoSchemas.kt deleted file mode 100644 index d83cef6..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/DemoSchemas.kt +++ /dev/null @@ -1,48 +0,0 @@ -package scribe.demo.data - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class CheckoutMeta( - @SerialName("item_count") - val itemCount: Int, - @SerialName("subtotal_cents") - val subtotalCents: Int, - @SerialName("feature_flag") - val featureFlag: String, -) - -@Serializable -data class SerializationBuyer( - val id: String, - val tier: String, - val email: String, -) - -@Serializable -data class SerializationLineItem( - val sku: String, - val quantity: Int, - @SerialName("unit_price_cents") - val unitPriceCents: Int, -) - -@Serializable -data class SerializationPayment( - val method: String, - val installments: Int, - val currency: String, -) - -@Serializable -data class SerializationOrderSnapshot( - @SerialName("order_id") - val orderId: String, - val buyer: SerializationBuyer, - @SerialName("line_items") - val lineItems: List, - val payment: SerializationPayment, - val tags: List, - val metadata: Map, -) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/TimelineItem.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/TimelineItem.kt deleted file mode 100644 index 75e6f5b..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/TimelineItem.kt +++ /dev/null @@ -1,8 +0,0 @@ -package scribe.demo.data - -data class TimelineItem( - val title: String, - val detail: String, - val payload: String, - val success: Boolean, -) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt deleted file mode 100644 index 98a2a9f..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt +++ /dev/null @@ -1,71 +0,0 @@ -package scribe.demo.scribe - -import com.rafambn.scribe.Archivist -import com.rafambn.scribe.Margin -import com.rafambn.scribe.Scribe -import com.rafambn.scribe.Scroll -import com.rafambn.scribe.Entry -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull -import scribe.demo.currentEpochMillis -import scribe.demo.data.sampleImprint -import scribe.demo.platformName -import kotlin.time.Duration.Companion.milliseconds - -class AppScribe( - onRecord: (Entry) -> Unit, - override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)?, -) : Scribe() { - - override val bufferCapacity: Int = 2 - override val bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST - - var overflowDelay: Boolean = false - - override val archivists = listOf( - Archivist { entry -> - if (entry["tag"]?.jsonPrimitive?.contentOrNull == "archivist_failure") { - error("Intentional archivist failure from showcase demo") - } - }, - Archivist { entry -> - if (overflowDelay) delay(220.milliseconds) - onRecord(entry) - }, - ) - - override val imprint = sampleImprint(platformName()) + mapOf( - "output" to JsonPrimitive("console"), - "session_kind" to JsonPrimitive("persistent-demo"), - ) - - override val margins = object : Margin { - override fun header(scroll: Scroll) { - scroll["started_at"] = JsonPrimitive(currentEpochMillis()) - scroll["platform_session"] = JsonPrimitive(platformName()) - } - - override fun footer(scroll: Scroll) { - val startedAt = scroll["started_at"]?.jsonPrimitive?.longOrNull ?: return - val completedAt = currentEpochMillis() - scroll["completed_at"] = JsonPrimitive(completedAt) - scroll["elapsed_ms"] = JsonPrimitive(completedAt - startedAt) - } - } - - override val onIgnition: (Throwable) -> Unit = { throwable -> - println("Scribe onIgnition: ${throwable.message ?: throwable}") - } - - fun close(scope: CoroutineScope) { - scope.launch { - runCatching { retire() } - } - } -} diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt deleted file mode 100644 index 12a9cb2..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt +++ /dev/null @@ -1,308 +0,0 @@ -package scribe.demo.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import scribe.demo.data.TimelineItem -import scribe.demo.platformName - -@Composable -fun HomeContent( - isBusy: Boolean, - busyLabel: String, - outputMessage: String, - statusMessage: String, - isRetired: Boolean, - ignitionMessage: String, - activeScrollIds: List, - archivistErrors: List, - lastRecord: String, - timeline: List, - onRunQuickScrollScenario: () -> Unit, - onRunSecondQuickScrollScenario: () -> Unit, - onRunCheckoutScenario: () -> Unit, - onRunInspectionScenario: () -> Unit, - onRunMarginScenario: () -> Unit, - onRunJsonSerializationScenario: () -> Unit, - onRunStringTemplateScenario: () -> Unit, - onRunArchivistScenario: () -> Unit, - onRunOverflowScenario: () -> Unit, - onRunArchivistFailureScenario: () -> Unit, - onRehireMainScribe: () -> Unit, - onRunRetireScenario: () -> Unit, - onRunPlanRetireScenario: () -> Unit, - onWireIgnitionScenario: () -> Unit, -) { - MaterialTheme { - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - Color(0xFFF6F1E8), - Color(0xFFE8F0EE), - Color(0xFFF8F6F2), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - HeroCard() - StatusCard( - outputMessage = outputMessage, - statusMessage = statusMessage, - isRetired = isRetired, - ignitionMessage = ignitionMessage, - activeScrollIds = activeScrollIds, - archivistErrors = archivistErrors, - isBusy = isBusy, - busyLabel = busyLabel, - ) - ActionGroup( - title = "Quick Scrolls", - description = "Immediately sealed one-shot scroll events.", - buttons = listOf( - "Emit quick scroll" to onRunQuickScrollScenario, - "Emit second quick scroll" to onRunSecondQuickScrollScenario, - ), - enabled = !isBusy, - ) - ActionGroup( - title = "Scrolls", - description = "Wide-event flows with generated/custom IDs, direct map writes, and margins.", - buttons = listOf( - "Checkout flow" to onRunCheckoutScenario, - "Map read/remove" to onRunInspectionScenario, - "Margins + seal(failure)" to onRunMarginScenario, - ), - enabled = !isBusy, - ) - ActionGroup( - title = "Console Rendering Checks", - description = "Validate nested JSON object serialization and string-template rendering in console records.", - buttons = listOf( - "JSON object serialization" to onRunJsonSerializationScenario, - "String template message" to onRunStringTemplateScenario, - ), - enabled = !isBusy, - ) - ActionGroup( - title = "Archivists And Delivery", - description = "Use the archivist types, queue overflow behavior, and archivist error handling.", - buttons = listOf( - "Archivist mixed flow" to onRunArchivistScenario, - "Overflow demo" to onRunOverflowScenario, - "Archivist failure demo" to onRunArchivistFailureScenario, - ), - enabled = !isBusy, - ) - ActionGroup( - title = "Lifecycle And Safety", - description = "Pause and resume processing, then wire the onIgnition callback safely.", - buttons = listOf( - "Re-hire Scribe" to onRehireMainScribe, - "dismiss() (light queue)" to onRunRetireScenario, - "dismiss() preserving backlog" to onRunPlanRetireScenario, - "Wire onIgnition" to onWireIgnitionScenario, - ), - enabled = !isBusy, - ) - TimelineCard(lastRecord, timeline) - } - } - } -} - -@Composable -private fun HeroCard() { - Card( - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = Color(0xFF14213D)), - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = "Scribe Console Showcase", - color = Color(0xFFFFF7E6), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = "Guided demos for quick scrolls, wide events, margins, queue delivery, and archivist behavior. Every delivered record is printed to the console.", - color = Color(0xFFE7ECEF), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = "Platform: ${platformName()}", - color = Color(0xFFFCA311), - style = MaterialTheme.typography.labelLarge, - ) - } - } -} - -@Composable -private fun StatusCard( - outputMessage: String, - statusMessage: String, - isRetired: Boolean, - ignitionMessage: String, - activeScrollIds: List, - archivistErrors: List, - isBusy: Boolean, - busyLabel: String, -) { - Card(shape = RoundedCornerShape(24.dp)) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("Console Output", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text(outputMessage, style = MaterialTheme.typography.bodyMedium) - Text("Status: $statusMessage", style = MaterialTheme.typography.bodyMedium) - Text( - "Scribe instance: ${if (isRetired) "retired" else "active"}", - style = MaterialTheme.typography.bodyMedium, - color = if (isRetired) Color(0xFF9C2F2F) else Color(0xFF1D5C63), - ) - Text("Ignition: $ignitionMessage", style = MaterialTheme.typography.bodyMedium) - if (activeScrollIds.isNotEmpty()) { - Text( - "Active scrolls: ${activeScrollIds.joinToString()}", - style = MaterialTheme.typography.bodyMedium, - ) - } - if (archivistErrors.isNotEmpty()) { - Text( - "Archivist errors: ${archivistErrors.joinToString()}", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF9C2F2F), - ) - } - if (isBusy) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) - Text(busyLabel, style = MaterialTheme.typography.bodyMedium) - } - } - } - } -} - -@Composable -private fun ActionGroup( - title: String, - description: String, - buttons: List Unit>>, - enabled: Boolean, -) { - Card(shape = RoundedCornerShape(24.dp)) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text(description, style = MaterialTheme.typography.bodyMedium) - buttons.forEach { (label, action) -> - OutlinedButton( - onClick = action, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - ) { - Text(label) - } - } - } - } -} - -@Composable -private fun TimelineCard(lastRecord: String, timeline: List) { - Card(shape = RoundedCornerShape(24.dp)) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("Timeline", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text("Last console record", style = MaterialTheme.typography.bodyMedium) - if (lastRecord.isNotBlank()) { - Surface( - color = Color(0xFF101820), - shape = RoundedCornerShape(18.dp), - ) { - SelectionContainer { - Text( - text = lastRecord, - modifier = Modifier.padding(14.dp), - color = Color(0xFFE9F1F7), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - timeline.forEachIndexed { index, item -> - if (index > 0) { - HorizontalDivider() - } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - item.title, - style = MaterialTheme.typography.titleMedium, - color = if (item.success) Color(0xFF1D5C63) else Color(0xFF9C2F2F), - ) - Text(item.detail, style = MaterialTheme.typography.bodyMedium) - if (item.payload.isNotBlank()) { - Surface( - color = Color(0xFFF2EFEA), - shape = RoundedCornerShape(14.dp), - ) { - SelectionContainer { - Text( - item.payload, - modifier = Modifier.padding(12.dp), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } - } - } - } -} diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt deleted file mode 100644 index 4923741..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt +++ /dev/null @@ -1,44 +0,0 @@ -package scribe.demo.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember - -@Composable -fun HomeScreen() { - val viewModel = remember { HomeViewModel() } - val state by viewModel.state.collectAsState() - - DisposableEffect(viewModel) { - onDispose { viewModel.close() } - } - - HomeContent( - isBusy = state.isBusy, - busyLabel = state.busyLabel, - outputMessage = state.outputMessage, - statusMessage = state.statusMessage, - isRetired = state.isRetired, - ignitionMessage = state.ignitionMessage, - activeScrollIds = state.activeScrollIds, - archivistErrors = state.archivistErrors, - lastRecord = state.lastRecord, - timeline = state.timeline, - onRunQuickScrollScenario = viewModel::runQuickScrollScenario, - onRunSecondQuickScrollScenario = viewModel::runSecondQuickScrollScenario, - onRunCheckoutScenario = viewModel::runCheckoutScenario, - onRunInspectionScenario = viewModel::runInspectionScenario, - onRunMarginScenario = viewModel::runMarginScenario, - onRunJsonSerializationScenario = viewModel::runJsonSerializationScenario, - onRunStringTemplateScenario = viewModel::runStringTemplateScenario, - onRunArchivistScenario = viewModel::runArchivistScenario, - onRunOverflowScenario = viewModel::runOverflowScenario, - onRunArchivistFailureScenario = viewModel::runArchivistFailureScenario, - onRehireMainScribe = viewModel::rehireMainScribe, - onRunRetireScenario = viewModel::runRetireScenario, - onRunPlanRetireScenario = viewModel::runPlanRetireScenario, - onWireIgnitionScenario = viewModel::wireIgnitionScenario, - ) -} diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt deleted file mode 100644 index 21ada57..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package scribe.demo.ui - -import scribe.demo.data.TimelineItem - -data class HomeState( - val isBusy: Boolean = false, - val busyLabel: String = "", - val statusMessage: String = "Ready to run demo scenarios.", - val isRetired: Boolean = false, - val activeScrollIds: List = emptyList(), - val lastRecord: String = "", - val outputMessage: String = "Records are written to the application console.", - val archivistErrors: List = emptyList(), - val timeline: List = emptyList(), - val ignitionMessage: String = "The onIgnition hook is wired, but the demo does not crash itself to trigger it.", -) \ No newline at end of file diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt deleted file mode 100644 index b640ae4..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt +++ /dev/null @@ -1,432 +0,0 @@ -package scribe.demo.ui - -import com.rafambn.scribe.Entry -import com.rafambn.scribe.Scribe -import com.rafambn.scribe.Scroll -import com.rafambn.scribe.id -import com.rafambn.scribe.seal -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import scribe.demo.currentEpochMillis -import scribe.demo.data.CheckoutMeta -import scribe.demo.data.SerializationBuyer -import scribe.demo.data.SerializationLineItem -import scribe.demo.data.SerializationOrderSnapshot -import scribe.demo.data.SerializationPayment -import scribe.demo.data.TimelineItem -import scribe.demo.data.consoleRecordFromEntry -import scribe.demo.data.payloadEventKind -import scribe.demo.data.recordSummary -import scribe.demo.platformName -import scribe.demo.scribe.AppScribe -import kotlin.collections.set -import kotlin.time.Duration.Companion.milliseconds - -class HomeViewModel { - private val json = Json { - prettyPrint = true - prettyPrintIndent = " " - encodeDefaults = true - } - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val gate = Mutex() - private val appVersion = "testApp-showcase" - private val platform = platformName() - private val activeScrolls = linkedMapOf() - private var printedEvents = 0 - - private val _state = MutableStateFlow(HomeState()) - val state: StateFlow = _state.asStateFlow() - - private val appScribe = AppScribe( - onRecord = { entry -> handleRecord(entry) }, - onArchiveFailure = { archivist, entry, error -> - appendArchivistError( - "Archivist failure in ${archivist::class.simpleName ?: "Archivist"} for ${entryKind()}: ${error.message ?: error}", - ) - }, - ) - - init { - appScribe.hire() - } - - fun runQuickScrollScenario() = launchScenario("Quick scroll emission demo") { - emitQuickScroll( - tag = "checkout", - message = "Started checkout for premium customer", - level = "INFO", - ) - updateStatus("Ran a quick scroll: one immediately sealed event printed through Archivist.") - } - - fun runSecondQuickScrollScenario() = launchScenario("Second quick scroll demo") { - emitQuickScroll( - tag = "queue", - message = "Queued retry audit event as an immediately sealed scroll", - level = "DEBUG", - ) - updateStatus("Ran a second immediately sealed scroll flow.") - } - - fun runStringTemplateScenario() = launchScenario("String template scroll demo") { - val scroll = openScroll(appScribe, id = "template-render-1") - scroll["demo_name"] = JsonPrimitive("string_template_render") - scroll["message"] = JsonPrimitive("error on order_id=\$order_id") - scroll["order_id"] = JsonPrimitive(555) - sealScroll(scroll, appScribe) - appendTimeline( - title = "Template message preview", - detail = "Sent scroll with {message: \"error on order_id=\$order_id\", order_id: 555}.", - payload = "", - success = true, - ) - updateStatus("Ran string-template scroll demo; inspect message + order_id in console output.") - } - - fun runCheckoutScenario() = launchScenario("Wide-event scroll demo") { - val scroll = openScroll(appScribe) - scroll["demo_name"] = JsonPrimitive("checkout_scroll") - scroll["order_id"] = JsonPrimitive("order-42") - scroll["gateway"] = JsonPrimitive("stripe") - scroll["attempt"] = JsonPrimitive(1) - scroll["retry"] = JsonPrimitive(false) - scroll["cart"] = json.encodeToJsonElement( - CheckoutMeta.serializer(), - CheckoutMeta( - itemCount = 3, - subtotalCents = 249_900, - featureFlag = "wide-events", - ), - ) - sealScroll(scroll, appScribe) - updateStatus("Ran newScroll + map writes + seal for a wide checkout event.") - } - - fun runInspectionScenario() = launchScenario("Scroll map inspection demo") { - val scroll = openScroll(appScribe, id = "ops-demo-42") - scroll["demo_name"] = JsonPrimitive("inspection_scroll") - scroll["phase"] = JsonPrimitive("validation") - scroll["retryable"] = JsonPrimitive(true) - scroll["attempt"] = JsonPrimitive(2) - - val visibleIds = activeScrolls.keys.toList() - val phase = scroll["phase"]?.toString() ?: "missing" - val removed = scroll.remove("retryable")?.toString() ?: "null" - - appendTimeline( - title = "Map read/remove", - detail = "Custom scroll id ops-demo-42 visible in ${visibleIds.joinToString()} ; phase=$phase ; removed retryable=$removed.", - payload = "", - success = true, - ) - sealScroll(scroll, appScribe) - updateStatus("Ran custom-id scroll demo with map reads/removals and local active-scroll tracking.") - } - - fun runMarginScenario() = launchScenario("Margin + seal(failure) demo") { - val scroll = openScroll(appScribe, id = "inventory-sync-1") - scroll["demo_name"] = JsonPrimitive("margin_scroll") - scroll["flow"] = JsonPrimitive("inventory-sync") - scroll["warehouse"] = JsonPrimitive("gru-1") - scroll["cache_hit"] = JsonPrimitive(false) - scroll["failure_reason"] = JsonPrimitive("downstream retry scheduled") - scroll["success"] = JsonPrimitive(false) - sealScroll(scroll, appScribe) - delay(250.milliseconds) - updateStatus("Ran Margin header/footer hooks on a failed scroll, with success recorded as a data field.") - } - - fun runJsonSerializationScenario() = launchScenario("JSON serialization scroll demo") { - val scroll = openScroll(appScribe, id = "json-serialization-1") - - val snapshot = SerializationOrderSnapshot( - orderId = "order-555", - buyer = SerializationBuyer( - id = "buyer-123", - tier = "gold", - email = "buyer-123@example.com", - ), - lineItems = listOf( - SerializationLineItem(sku = "SKU-CHAIR-42", quantity = 1, unitPriceCents = 129_900), - SerializationLineItem(sku = "SKU-LAMP-10", quantity = 2, unitPriceCents = 24_990), - ), - payment = SerializationPayment( - method = "credit_card", - installments = 3, - currency = "USD", - ), - tags = listOf("console", "serialization-test", "nested-object"), - metadata = mapOf( - "channel" to "stdout", - "experiment" to "console-json-object", - ), - ) - - scroll["demo_name"] = JsonPrimitive("json_serialization") - scroll["order_snapshot"] = json.encodeToJsonElement(SerializationOrderSnapshot.serializer(), snapshot) - scroll["order_id"] = JsonPrimitive(snapshot.orderId) - scroll["buyer_tier"] = JsonPrimitive(snapshot.buyer.tier) - scroll["primary_sku"] = JsonPrimitive(snapshot.lineItems.first().sku) - scroll["channel"] = JsonPrimitive(snapshot.metadata["channel"] ?: "unknown") - scroll["order_item_count"] = JsonPrimitive(snapshot.lineItems.sumOf { it.quantity }) - scroll["order_tag_count"] = JsonPrimitive(snapshot.tags.size) - scroll["expected_render_checks"] = JsonPrimitive( - "order_snapshot.order_id,order_snapshot.buyer.tier,order_snapshot.line_items[0].sku,order_snapshot.metadata.channel,order_id,buyer_tier,primary_sku,channel,order_item_count,order_tag_count", - ) - - sealScroll(scroll, appScribe) - updateStatus("Ran JSON serialization demo with a nested object payload for console inspection.") - } - - fun runArchivistScenario() = launchScenario("Unified Archivist demo") { - emitQuickScroll( - tag = "auth", - message = "Session accepted for staff dashboard", - level = "INFO", - ) - val scroll = openScroll(appScribe, id = "session-audit") - scroll["demo_name"] = JsonPrimitive("entry_archivist_demo") - scroll["role"] = JsonPrimitive("support") - scroll["elevated_access"] = JsonPrimitive(true) - sealScroll(scroll, appScribe) - updateStatus("Ran two scrolls through one Archivist path.") - } - - fun runOverflowScenario() = launchScenario("Overflow demo") { - val baseline = printedEvents - val attempted = 12 - - appScribe.overflowDelay = true - repeat(attempted) { index -> - emitQuickScroll( - tag = "buffer", - message = "burst event #$index", - level = if (index % 3 == 0) "WARN" else "INFO", - ) - } - delay(1800.milliseconds) - appScribe.overflowDelay = false - - val delivered = printedEvents - baseline - appendTimeline( - title = "Overflow result", - detail = "Attempted $attempted quick scrolls with private buffer capacity 2 and DROP_OLDEST; delivered $delivered.", - payload = "", - success = delivered < attempted, - ) - updateStatus("Ran overflow demo with the private buffer configured as DROP_OLDEST.") - } - - fun runArchivistFailureScenario() = launchScenario("Archivist error demo") { - emitQuickScroll( - tag = "archivist_failure", - message = "Intentional archivist failure probe", - level = "WARN", - ) - updateStatus("Archivist failure demo ran; onArchiveFailure captures the injected failure.") - } - - fun runRetireScenario() = launchScenario("dismiss() demo") { - emitQuickScroll("lifecycle", "dismiss() with light queue", "INFO") - val started = currentEpochMillis() - appScribe.dismiss() - val elapsed = currentEpochMillis() - started - - activeScrolls.clear() - _state.update { it.copy(isRetired = true) } - refreshActiveScrolls() - appendTimeline( - title = "dismiss()", - detail = "dismiss() finished in ${elapsed}ms and paused the job without closing intake.", - payload = "", - success = true, - ) - updateStatus("Processing is paused; new messages remain buffered until Re-hire Scribe is pressed.") - } - - fun runPlanRetireScenario() = launchScenario("dismiss() with backlog demo") { - repeat(6) { index -> - emitQuickScroll("lifecycle", "buffered probe #$index", "INFO") - } - val started = currentEpochMillis() - appScribe.dismiss() - val elapsed = currentEpochMillis() - started - - activeScrolls.clear() - _state.update { it.copy(isRetired = true) } - refreshActiveScrolls() - appendTimeline( - title = "dismiss() with backlog", - detail = "dismiss() took ${elapsed}ms and preserved the queued backlog.", - payload = "", - success = true, - ) - updateStatus("Processing is paused and queued work is preserved. Press Re-hire Scribe to drain it.") - } - - fun wireIgnitionScenario() = launchScenario("onIgnition wiring") { - emitQuickScroll( - tag = "ignition", - message = "onIgnition callback is configured; the demo avoids firing an uncaught exception.", - level = "INFO", - ) - _state.update { - it.copy( - ignitionMessage = "onIgnition is configured in this demo build. Triggering it live would terminate the app, so the showcase documents the hook instead of crashing itself.", - ) - } - updateStatus("Configured onIgnition safely without terminating the showcase process.") - } - - fun rehireMainScribe() = launchScenario("Re-hire Scribe") { - if (!_state.value.isRetired) { - updateStatus("The shared demo Scribe is already active.") - appendTimeline( - title = "Re-hire Scribe", - detail = "The shared demo Scribe was already active, so no recreation was needed.", - payload = "", - success = true, - ) - return@launchScenario - } - - appScribe.hire() - _state.update { it.copy(isRetired = false) } - refreshActiveScrolls() - updateStatus("The shared demo Scribe was re-hired and can send messages again.") - appendTimeline( - title = "Re-hire Scribe", - detail = "The shared demo Scribe object was hired again after retirement.", - payload = "", - success = true, - ) - } - - fun close() { - appScribe.close(scope) - } - - private fun launchScenario(label: String, block: suspend () -> Unit) { - scope.launch { - gate.withLock { - _state.update { it.copy(isBusy = true, busyLabel = label) } - try { - block() - } catch (error: Throwable) { - appendTimeline( - title = label, - detail = error.message ?: error.toString(), - payload = "", - success = false, - ) - updateStatus("Scenario failed: ${error.message ?: error}") - } finally { - _state.update { it.copy(isBusy = false, busyLabel = "") } - } - } - } - } - - private fun handleRecord(entry: Entry) { - val record = consoleRecordFromEntry( - entry = entry, - demoName = "shared_session", - platform = platform, - archivistType = "Archivist", - appVersion = appVersion, - recordedAt = currentEpochMillis(), - ) - - printedEvents += 1 - val payload = json.encodeToString(JsonObject.serializer(), JsonObject(record)) - println(payload) - _state.update { - it.copy( - lastRecord = payload, - outputMessage = "Printed ${payloadEventKind(record)} record to the console.", - ) - } - appendTimeline( - title = "${payloadEventKind(record)} via Archivist", - detail = "${recordSummary(record)}. Printed to console.", - payload = payload, - success = true, - ) - } - - private fun appendTimeline(title: String, detail: String, payload: String, success: Boolean) { - _state.update { - it.copy( - timeline = listOf(TimelineItem(title, detail, payload, success)) + it.timeline.take(19), - ) - } - } - - private fun appendArchivistError(message: String) { - println(message) - _state.update { - it.copy( - archivistErrors = listOf(message) + it.archivistErrors.take(5), - ) - } - appendTimeline( - title = "Archivist failure captured", - detail = message, - payload = "", - success = false, - ) - } - - private fun updateStatus(message: String) { - _state.update { it.copy(statusMessage = message) } - } - - private fun updateActiveScrolls(ids: List) { - _state.update { it.copy(activeScrollIds = ids) } - } - - private fun refreshActiveScrolls() { - if (_state.value.isRetired) { - updateActiveScrolls(emptyList()) - return - } - updateActiveScrolls(activeScrolls.keys.toList()) - } - - private fun openScroll(scribe: Scribe, id: String? = null): Scroll { - val scroll = scribe.newScroll(id = id) - activeScrolls[scroll.id] = scroll - refreshActiveScrolls() - return scroll - } - - private fun sealScroll(scroll: Scroll, scribe: Scribe) { - scroll.seal(scribe) - activeScrolls.remove(scroll.id) - refreshActiveScrolls() - } - - private fun entryKind(): String = "scroll" - - private fun emitQuickScroll(tag: String, message: String, level: String) { - val scroll = appScribe.newScroll() - scroll["demo_name"] = JsonPrimitive("quick_scroll") - scroll["tag"] = JsonPrimitive(tag) - scroll["message"] = JsonPrimitive(message) - scroll["level"] = JsonPrimitive(level) - scroll.seal(appScribe) - } -} diff --git a/testApp/shared/src/iosMain/kotlin/scribe/demo/WorldIos.kt b/testApp/shared/src/iosMain/kotlin/scribe/demo/WorldIos.kt deleted file mode 100644 index 9ec5442..0000000 --- a/testApp/shared/src/iosMain/kotlin/scribe/demo/WorldIos.kt +++ /dev/null @@ -1,7 +0,0 @@ -package scribe.demo - -import kotlin.time.Clock - -actual fun platformName() = "iOS" - -actual fun currentEpochMillis(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/testApp/shared/src/jvmMain/kotlin/scribe/demo/WorldJvm.kt b/testApp/shared/src/jvmMain/kotlin/scribe/demo/WorldJvm.kt deleted file mode 100644 index 22afcd9..0000000 --- a/testApp/shared/src/jvmMain/kotlin/scribe/demo/WorldJvm.kt +++ /dev/null @@ -1,5 +0,0 @@ -package scribe.demo - -actual fun platformName() = "JVM" - -actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/testServer/build.gradle.kts b/testServer/build.gradle.kts new file mode 100644 index 0000000..3b21765 --- /dev/null +++ b/testServer/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + kotlin("jvm") + application +} + +kotlin { + jvmToolchain(11) +} + +dependencies { + implementation(project(":scribe-slf4j")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) +} + +application { + mainClass = "com.rafambn.scribe.testserver.MainKt" +} diff --git a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt new file mode 100644 index 0000000..1676ef6 --- /dev/null +++ b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt @@ -0,0 +1,34 @@ +package com.rafambn.scribe.testserver + +import com.rafambn.scribe.seal +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler +import kotlinx.serialization.json.JsonPrimitive +import java.nio.charset.StandardCharsets +import org.slf4j.LoggerFactory +import org.slf4j.MDC + +object HealthHandler : HttpHandler { + private val logger = LoggerFactory.getLogger(HealthHandler::class.java) + + override fun handle(exchange: HttpExchange) { + val scroll = TestServerScribe.newScroll() + scroll["normal-scroll"] = JsonPrimitive("normal-scroll") + scroll.seal(TestServerScribe) + try { + MDC.put("http.method", exchange.requestMethod) + MDC.put("http.path", exchange.requestURI.path) + logger.info("Health check requested") + exchange.respond(statusCode = 200, body = "OK\n") + } finally { + MDC.clear() + } + } +} + +private fun HttpExchange.respond(statusCode: Int, body: String) { + val response = body.toByteArray(StandardCharsets.UTF_8) + responseHeaders.set("Content-Type", "text/plain; charset=utf-8") + sendResponseHeaders(statusCode, response.size.toLong()) + responseBody.use { it.write(response) } +} diff --git a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt new file mode 100644 index 0000000..1dbd1fb --- /dev/null +++ b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt @@ -0,0 +1,28 @@ +package com.rafambn.scribe.testserver + +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory + +private const val SERVER_PORT = 8080 +private val logger = LoggerFactory.getLogger("TestServer") + +fun main() { + TestServerScribe.hire() + + val server = HttpServer.create(InetSocketAddress(SERVER_PORT), 0).apply { + createContext("/health", HealthHandler) + executor = null + start() + } + + Runtime.getRuntime().addShutdownHook( + Thread { + server.stop(0) + runBlocking { TestServerScribe.retire() } + }, + ) + + logger.info("Test server started on http://localhost:{}/health", SERVER_PORT) +} diff --git a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/TestServerScribe.kt b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/TestServerScribe.kt new file mode 100644 index 0000000..1f736f8 --- /dev/null +++ b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/TestServerScribe.kt @@ -0,0 +1,22 @@ +package com.rafambn.scribe.testserver + +import com.rafambn.scribe.Archivist +import com.rafambn.scribe.Entry +import com.rafambn.scribe.slf4j.ScribeBackend +import com.rafambn.scribe.slf4j.Slf4jScribe +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.serialization.json.JsonPrimitive + +@ScribeBackend +object TestServerScribe : Slf4jScribe() { + override val archivists = listOf( + Archivist { entry: Entry -> println(entry) }, + ) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit) = + { _, _, error -> error.printStackTrace() } + + override val imprint = mapOf("imprint" to JsonPrimitive("imprint value")) + +} From 82ae6ae3f7386318907a6890b54db0f3da6aa177 Mon Sep 17 00:00:00 2001 From: rafambn Date: Tue, 11 Aug 2026 09:41:04 -0300 Subject: [PATCH 08/11] Refactor retirement logic to improve lifecycle handling and add resilience tests; bump version to 0.6.0 --- docs/scribe-logo.svg | 2 +- scribe-logo.svg | 2 +- scribe-slf4j/build.gradle.kts | 2 +- .../rafambn/scribe/slf4j/NamedScribeLogger.kt | 2 +- .../scribe/slf4j/ScribeServiceProvider.kt | 2 +- .../rafambn/scribe/slf4j/ScribeSLF4JTest.kt | 4 +-- scribe/build.gradle.kts | 2 +- .../kotlin/com/rafambn/scribe/Scribe.kt | 27 ++++++++------- .../kotlin/com/rafambn/scribe/Scroll.kt | 4 +-- .../scribe/ScribeDeliveryRetireTest.kt | 33 ++++++++++++++++++- .../scribe/testserver/HealthHandler.kt | 2 +- .../com/rafambn/scribe/testserver/Main.kt | 2 +- 12 files changed, 59 insertions(+), 25 deletions(-) diff --git a/docs/scribe-logo.svg b/docs/scribe-logo.svg index 7204393..80e84fd 100644 --- a/docs/scribe-logo.svg +++ b/docs/scribe-logo.svg @@ -1,6 +1,6 @@ + xmlns="http://www.w3.org/2000/svg"> diff --git a/scribe-logo.svg b/scribe-logo.svg index 7204393..80e84fd 100644 --- a/scribe-logo.svg +++ b/scribe-logo.svg @@ -1,6 +1,6 @@ + xmlns="http://www.w3.org/2000/svg"> diff --git a/scribe-slf4j/build.gradle.kts b/scribe-slf4j/build.gradle.kts index ef85355..e66871a 100644 --- a/scribe-slf4j/build.gradle.kts +++ b/scribe-slf4j/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "com.rafambn" -version = "0.5.0" +version = "0.6.0" kotlin { jvmToolchain(11) diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt index ed62108..6576613 100644 --- a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt @@ -1,7 +1,7 @@ package com.rafambn.scribe.slf4j -import org.slf4j.Marker import org.slf4j.MDC +import org.slf4j.Marker import org.slf4j.event.Level import org.slf4j.helpers.AbstractLogger diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt index cb852d8..f4a6344 100644 --- a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt @@ -4,8 +4,8 @@ import io.github.classgraph.ClassGraph import kotlinx.coroutines.runBlocking import org.slf4j.ILoggerFactory import org.slf4j.IMarkerFactory -import org.slf4j.helpers.BasicMarkerFactory import org.slf4j.helpers.BasicMDCAdapter +import org.slf4j.helpers.BasicMarkerFactory import org.slf4j.spi.MDCAdapter import org.slf4j.spi.SLF4JServiceProvider diff --git a/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt b/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt index 85bfa09..a20f3a4 100644 --- a/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt +++ b/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt @@ -3,9 +3,8 @@ package com.rafambn.scribe.slf4j import com.rafambn.scribe.Archivist import com.rafambn.scribe.Entry import com.rafambn.scribe.seal -import java.util.concurrent.CopyOnWriteArrayList -import kotlinx.coroutines.delay import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonPrimitive @@ -14,6 +13,7 @@ import org.slf4j.MDC import org.slf4j.Marker import org.slf4j.MarkerFactory import org.slf4j.event.Level +import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/scribe/build.gradle.kts b/scribe/build.gradle.kts index a988607..963d6c2 100644 --- a/scribe/build.gradle.kts +++ b/scribe/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } group = "com.rafambn" -version = "0.5.0" +version = "0.6.0" kotlin { jvm { diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 5c5461a..f3b9e96 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -171,22 +171,25 @@ abstract class Scribe { "retire() cannot be called from an archivist; request it from the lifecycle owner." } - if (!retiring.compareAndSet(expectedValue = false, newValue = true)) { - retirementCompleted.await() - return - } - - try { + if (retiring.compareAndSet(expectedValue = false, newValue = true)) { intakeOpen.store(false) processingEnabled.value = true queue.close() - processorJob.join() - ownedScope.cancel() - retirementCompleted.complete(Unit) - } catch (error: Throwable) { - retirementCompleted.completeExceptionally(error) - throw error + + val processor = processorJob + ownedScope.launch { + try { + processor.join() + retirementCompleted.complete(Unit) + } catch (error: Throwable) { + retirementCompleted.completeExceptionally(error) + } finally { + ownedScope.cancel() + } + } } + + retirementCompleted.await() } /** diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt index ab76c67..ac7322d 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt @@ -1,10 +1,10 @@ package com.rafambn.scribe -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid typealias Scroll = MutableMap diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt index 8871057..7adf7e3 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt @@ -5,12 +5,13 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals @@ -233,6 +234,36 @@ class ScribeDeliveryRetireTest { } } + @Test + fun cancelling_retire_caller_does_not_cancel_retirement() { + runSuspend { + coroutineScope { + val gate = CompletableDeferred() + val firstWriteStarted = CompletableDeferred() + val shelf = BlockingShelf(gate, firstWriteStarted) + val scribe = scribeWithScrollShelves(shelf) + + scribe.newScroll(id = "survives-cancellation").seal(scribe) + firstWriteStarted.await() + + val retireJob = launch { scribe.retire() } + withTimeout(2_000.milliseconds) { + while (scribe.isIntakeOpen) yield() + } + withTimeout(2_000.milliseconds) { retireJob.cancelAndJoin() } + assertTrue(retireJob.isCancelled) + + gate.complete(Unit) + withTimeout(2_000.milliseconds) { scribe.retire() } + + assertEquals( + "survives-cancellation", + shelf.events.single()["scroll_id"]?.jsonPrimitive?.content, + ) + } + } + } + @Test fun dismiss_called_from_archivist_does_not_deadlock() { runSuspend { diff --git a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt index 1676ef6..f266b13 100644 --- a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt +++ b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt @@ -4,9 +4,9 @@ import com.rafambn.scribe.seal import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpHandler import kotlinx.serialization.json.JsonPrimitive -import java.nio.charset.StandardCharsets import org.slf4j.LoggerFactory import org.slf4j.MDC +import java.nio.charset.StandardCharsets object HealthHandler : HttpHandler { private val logger = LoggerFactory.getLogger(HealthHandler::class.java) diff --git a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt index 1dbd1fb..ca10def 100644 --- a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt +++ b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt @@ -1,9 +1,9 @@ package com.rafambn.scribe.testserver import com.sun.net.httpserver.HttpServer -import java.net.InetSocketAddress import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory +import java.net.InetSocketAddress private const val SERVER_PORT = 8080 private val logger = LoggerFactory.getLogger("TestServer") From 1abd12f5c9be7c1453b8df66fd8154c858a9ff2f Mon Sep 17 00:00:00 2001 From: rafambn Date: Tue, 11 Aug 2026 10:02:38 -0300 Subject: [PATCH 09/11] Add SLF4J integration, update documentation, and bump version to 0.6.0 --- README.md | 29 ++++++++++------------------- docs/api-concepts.md | 15 ++++++++------- docs/getting-started.md | 12 +++++------- docs/index.md | 1 + docs/lifecycle-and-delivery.md | 10 ++++++++++ mkdocs.yml | 1 + 6 files changed, 35 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e063acc..e21003e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - Delivery hooks through `Archivist` instances receiving `Entry` snapshots - Scroll lifecycle enrichment through `Margin` - Independent `Scribe` objects for applications and imported libraries +- A JVM SLF4J 2.x provider backed by the same structured logging pipeline ## Setup @@ -44,7 +45,7 @@ Add Scribe to your `commonMain` dependencies: kotlin { sourceSets { commonMain.dependencies { - implementation("com.rafambn:scribe:0.5.0") + implementation("com.rafambn:scribe:0.6.0") } } } @@ -56,12 +57,9 @@ Create a `Scribe` object, start processing its private buffer, and emit a scroll ```kotlin object AppScribe : Scribe() { - override val bufferCapacity = 256 - override val bufferOverflow = BufferOverflow.DROP_OLDEST - override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null override val archivists: List = listOf( - Archivist { scroll -> - println(scroll) + Archivist { entry -> + println(entry) } ) } @@ -78,11 +76,8 @@ Use a scroll when you need shared context for a longer flow: ```kotlin object BillingScribe : Scribe() { - override val bufferCapacity = 256 - override val bufferOverflow = BufferOverflow.DROP_OLDEST - override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null override val archivists: List = listOf( - Archivist { scroll -> println(scroll) } + Archivist { entry -> println(entry) } ) override val imprint = mapOf( "service" to JsonPrimitive("billing"), @@ -106,7 +101,7 @@ For JVM applications, add the SLF4J provider: ```kotlin dependencies { - implementation("com.rafambn:scribe-slf4j:0.5.0") + implementation("com.rafambn:scribe-slf4j:0.6.0") } ``` @@ -134,16 +129,12 @@ The provider discovers the annotated backend once on the first SLF4J access and `scribe-slf4j` is a standalone SLF4J provider. Do not include another provider such as `logback-classic` in the same runtime classpath. -Choose the archivist that matches your output flow: - -```kotlin -val scrollArchivist = Archivist { scroll -> println(scroll) } -``` +See the [full documentation](https://scribe.rafambn.com/) for lifecycle controls, overflow behavior, margins, and SLF4J field mapping. ## Performance Scribe is designed for high-throughput and thread-safe concurrent logging. -Benchmark results (measured on JVM): -- **In-memory ingestion**: ~830,000 logs/sec (Concurrent) -- **Safe File Writing**: ~130,000 logs/sec (Concurrent, verified no corruption) +The repository includes JVM throughput tests for concurrent in-memory ingestion and serialized file +writing. Results depend on the machine, runtime, buffer configuration, and archivist implementation; +run the tests in your target environment before using them for capacity planning. diff --git a/docs/api-concepts.md b/docs/api-concepts.md index a43754c..b43dc36 100644 --- a/docs/api-concepts.md +++ b/docs/api-concepts.md @@ -5,13 +5,13 @@ Scribe models logging with structured scroll events: - `Scroll`: a mutable JSON-map you build up and then pass to `seal(...)` -- `Entry`: typealias for `Map`, the immutable snapshot produced by sealing a `Scroll` and delivered through a runtime's savers +- `Entry`: typealias for `Map`, the read-only snapshot produced by sealing a `Scroll` and delivered through a runtime's archivists ## Terminology - `newScroll(...)`: starts a contextual logging session - `seal(scribe)`: applies the supplied runtime's footer, snapshots the - current scroll data, and emits an `Entry` + current scroll data, attempts a non-blocking enqueue, and returns the `Entry` - `extend(scroll)`: copies missing keys from another scroll into this one - `append(key, scroll)`: nests a scroll as a JSON object under the given key - `Margin`: hook for writing fields at open/close boundaries @@ -25,7 +25,8 @@ Scribe models logging with structured scroll events: `Scribe` is an abstract runtime base class. A user creates one or more objects that extend it. Each object owns: -- one or more savers (`shelves`) +- zero or more configured archivists (at least one is required when `hire()` is called) +- a private buffer with a capacity and overflow policy - an optional shared `imprint` - optional lifecycle hooks through `Margin` - optional uncaught exception wiring through `onIgnition` (the installed @@ -86,9 +87,8 @@ val scroll = CheckoutScribe.newScroll(id = "checkout-42") println(scroll.id) // "checkout-42" ``` -Calling `seal(...)` more than once is allowed. Each call emits a separate -`Entry` through the `Scribe` passed to that call, with a snapshot of the data -at that point. +Calling `seal(...)` more than once is allowed. Each call applies the footer again, creates and +returns a separate `Entry` snapshot, and attempts delivery through the supplied `Scribe`. ## `Scroll` Operations @@ -150,7 +150,8 @@ CheckoutScribe.hire() ``` The delivery coroutine is owned by the `Scribe` instance so it can remain alive while processing -is paused and resume on a later `hire()`. +is paused and resume on a later `hire()`. The configuration properties have defaults; only +`archivists` normally needs to be overridden for a minimal implementation. ## Event Shapes diff --git a/docs/getting-started.md b/docs/getting-started.md index d96d11d..b1fcd38 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,7 +8,7 @@ Use the library from shared code in your Kotlin Multiplatform module: kotlin { sourceSets { commonMain.dependencies { - implementation("com.rafambn:scribe:0.5.0") + implementation("com.rafambn:scribe:0.6.0") } } } @@ -20,11 +20,8 @@ Create an object that extends `Scribe`, configure its private buffer, then hire ```kotlin object AppScribe : Scribe() { - override val bufferCapacity = 256 - override val bufferOverflow = BufferOverflow.DROP_OLDEST - override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null - override val archivists: List = listOf(Archivist { scroll -> - println(scroll) + override val archivists: List = listOf(Archivist { entry -> + println(entry) }) } @@ -133,9 +130,10 @@ val scrollArchivist = Archivist { scroll -> println(scroll) } - Every archivist receives `Entry` snapshots - `Archivist` is a functional interface: `Archivist { entry -> ... }` is all you need -- Add multiple savers to a `Scribe` object to fan out to several outputs +- Add multiple archivists to a `Scribe` object to fan out to several outputs ## What to Read Next - [API Concepts](api-concepts.md) for the core types and terminology - [Lifecycle and Delivery](lifecycle-and-delivery.md) for intake, processing, retirement, and archivist error callbacks +- [SLF4J Provider](slf4j.md) for using Scribe as a JVM SLF4J 2.x provider diff --git a/docs/index.md b/docs/index.md index 8deb1b3..9618dee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,3 +37,4 @@ That pushes logging toward "what happened to this request or workflow?" instead - [Getting Started](getting-started.md) for setup and first usage - [API Concepts](api-concepts.md) for terminology and data model - [Lifecycle and Delivery](lifecycle-and-delivery.md) for buffering, intake, processing, and retirement +- [SLF4J Provider](slf4j.md) for routing JVM SLF4J 2.x calls through Scribe diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index 317b66f..904cd84 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -52,6 +52,10 @@ resulting `Entry` in the private buffer. It is non-suspending and never blocks w space. Entries rejected because intake is closed, the buffer is full with `SUSPEND`, or retirement has begun are not delivered. Prefer `DROP_OLDEST` or `DROP_LATEST` for synchronous logging. +`seal(...)` always returns the created snapshot; its return value does not indicate whether the +buffer accepted it. With `DROP_LATEST`, the channel can report a successful send while discarding +the new entry according to that overflow policy. + Multiple calls to `seal(...)` on the same `Scroll` intentionally create separate snapshots. ## Terminal Retirement @@ -66,6 +70,12 @@ It closes intake and the private buffer, finishes the active archivist call, dra entries, and releases the internally owned scope. Intake and processing cannot restart afterward. +Concurrent or repeated callers wait for the same retirement operation. Cancelling one waiting +caller does not cancel the drain, and a later `retire()` call can still await its completion. +Calling `retire()` from an archivist or one of its child coroutines throws an +`IllegalStateException` to avoid waiting on the processor from within its own job tree; request +retirement from the application lifecycle owner instead. + The JVM SLF4J provider registers a shutdown hook that calls `retire()` automatically. It does not call `hire()`: the application chooses when processing begins, while earlier SLF4J calls accumulate in the backend's private buffer. diff --git a/mkdocs.yml b/mkdocs.yml index fab8371..a0e5abf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,7 @@ nav: - Getting Started: getting-started.md - API Concepts: api-concepts.md - Lifecycle and Delivery: lifecycle-and-delivery.md + - SLF4J Provider: slf4j.md markdown_extensions: - admonition From 79c7bbe2354c045f69755b1917583ffa95c7b0bd Mon Sep 17 00:00:00 2001 From: rafambn Date: Tue, 11 Aug 2026 10:03:21 -0300 Subject: [PATCH 10/11] Add SLF4J provider usage guide documentation --- docs/slf4j.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/slf4j.md diff --git a/docs/slf4j.md b/docs/slf4j.md new file mode 100644 index 0000000..636e9e7 --- /dev/null +++ b/docs/slf4j.md @@ -0,0 +1,105 @@ +# SLF4J Provider + +The `scribe-slf4j` module is a JVM SLF4J 2.x provider. It converts conventional SLF4J calls into +Scribe `Entry` snapshots and sends them through one application-wide `Slf4jScribe` backend. + +## Install + +Add the provider to a JVM application's runtime dependencies: + +```kotlin +dependencies { + implementation("com.rafambn:scribe-slf4j:0.6.0") +} +``` + +The module exposes the SLF4J API and the core `scribe` module transitively. It is itself an SLF4J +provider, so do not put another provider such as `logback-classic` on the same runtime classpath. + +## Define the Backend + +Annotate exactly one Kotlin `object` that extends `Slf4jScribe`: + +```kotlin +@ScribeBackend +object AppScribe : Slf4jScribe() { + override val bufferCapacity = 1_024 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val archivists = listOf( + Archivist { entry -> println(entry) }, + ) + + override val onArchiveFailure = + { _: Archivist, _: Entry, error: Throwable -> error.printStackTrace() } + + override fun isEnabled( + loggerName: String, + level: Level, + marker: Marker?, + ): Boolean = level.toInt() >= Level.INFO.toInt() +} +``` + +The provider scans the runtime classpath on its first initialization. Initialization fails if it +finds no annotated backend, more than one, a class that does not extend `Slf4jScribe`, or an +annotated class that is not a Kotlin object. + +## Start Processing + +SLF4J calls are accepted as soon as the provider is initialized, but processing starts dismissed. +Call `hire()` during application startup: + +```kotlin +fun main() { + AppScribe.hire() + + val logger = LoggerFactory.getLogger("checkout") + logger.info("Starting order {}", 42) +} +``` + +Calls made before `hire()` accumulate according to the backend's capacity and overflow policy. +The provider registers a JVM shutdown hook that calls `retire()` and drains accepted entries. You +may also retire explicitly from your lifecycle owner; repeated calls await the same terminal +retirement operation. + +## Default Entry Mapping + +The default `handleNormalizedLoggingCall(...)` uses SLF4J's message formatter and writes these +fields: + +| Field | When present | Value | +|---|---|---| +| `scroll_id` | always | generated Scribe scroll ID | +| `level` | always | SLF4J level name | +| `logger` | always | requested logger name | +| `message` | always | formatted message | +| `marker` | when supplied | marker name | +| `exception` | when supplied | exception stack trace | + +The current MDC map is copied into the entry. MDC keys cannot replace the reserved fields above, +and they also do not replace fields already supplied by the backend's imprint or header margin. + +`isEnabled(loggerName, level, marker)` runs before a scroll is allocated. It can filter by logger, +level, and marker. All levels are disabled automatically while intake is closed or retirement is +in progress. + +## Customize the Mapping + +Override `handleNormalizedLoggingCall` when the default field names or message formatting do not +fit your schema. The call contains the logger name, level, marker, raw message pattern, arguments, +throwable, and an MDC snapshot: + +```kotlin +override fun handleNormalizedLoggingCall(call: ScribeLoggingCall) { + val scroll = newScroll() + scroll["severity"] = JsonPrimitive(call.level.name) + scroll["source"] = JsonPrimitive(call.loggerName) + scroll["template"] = JsonPrimitive(call.messagePattern.orEmpty()) + call.mdc["requestId"]?.let { scroll["request_id"] = JsonPrimitive(it) } + scroll.seal(this) +} +``` + +The override owns the mapping and must seal a scroll itself to deliver an entry. Call +`super.handleNormalizedLoggingCall(call)` instead when the default mapping is desired. From 50b0e5f616cd17044a97683a1422585c7999a23b Mon Sep 17 00:00:00 2001 From: rafambn Date: Tue, 11 Aug 2026 13:17:44 -0300 Subject: [PATCH 11/11] Add SLF4J test target to CI and fix minor formatting issues --- .github/workflows/gradle.yml | 2 ++ scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt | 2 +- scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt | 2 +- .../kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt | 4 ++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 247547c..28afa57 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -24,6 +24,8 @@ jobs: include: - target: jvmTest os: ubuntu-latest + - target: :scribe-slf4j:test + os: ubuntu-latest - target: iosArm64TestKlibrary os: macos-latest - target: testAndroidHostTest diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt index 4fb0837..21ec79a 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt @@ -8,4 +8,4 @@ fun interface Archivist { * Handles an emitted structured log. */ suspend fun write(event: Entry) -} \ No newline at end of file +} diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt index ac7322d..7099bf5 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt @@ -42,4 +42,4 @@ fun Scroll.extend(scroll: Scroll): Scroll { fun Scroll.append(key: String, scroll: Scroll): Scroll { this[key] = JsonObject(scroll.toMap()) return this -} \ No newline at end of file +} diff --git a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt index 19db4af..786c0c9 100644 --- a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt +++ b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt @@ -82,13 +82,13 @@ class ScribeFileThroughputTest { scribe.retire() archivist.close() - + val duration = start.elapsedNow() // Verification val lines = testFile.readLines() assertEquals(iterations, lines.size, "Line count mismatch. Possible data loss.") - + // Check for corruption (ensure each line is a valid JSON and belongs to Scribe) lines.forEach { line -> assertTrue(line.startsWith("{") && line.endsWith("}"), "Interleaved or corrupt line: $line")